manychat-ai-agent 0.24.1 → 0.26.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 +45 -0
- package/dist/agent/guardrails.d.ts +7 -1
- package/dist/agent/guardrails.js +56 -1
- package/dist/agent/prompt.d.ts +6 -0
- package/dist/agent/prompt.js +35 -3
- package/dist/agent/runner.js +17 -3
- package/dist/agent/tools.d.ts +17 -1
- package/dist/agent/tools.js +45 -3
- package/dist/config/loader.js +16 -0
- package/dist/contracts/config.d.ts +4 -0
- package/dist/contracts/config.js +23 -2
- package/dist/conversation/turns.d.ts +5 -0
- package/dist/conversation/turns.js +21 -4
- package/dist/routes/turn.js +57 -48
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.26.0](https://github.com/pedronastasi/manychat-ai-agent/compare/v0.25.0...v0.26.0) (2026-10-17)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* wait for a burst to end before calling the model ([729bfca](https://github.com/pedronastasi/manychat-ai-agent/commit/729bfca32c20ea1d44d6eef2438ab852ec1b9bfb))
|
|
9
|
+
* wait for a burst to end before calling the model ([2cd1d91](https://github.com/pedronastasi/manychat-ai-agent/commit/2cd1d915b046b0972b0ca303e34bc3286ac08ebd))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
* **deps:** update patch dependencies ([#239](https://github.com/pedronastasi/manychat-ai-agent/issues/239)) ([371bfaf](https://github.com/pedronastasi/manychat-ai-agent/commit/371bfaf4dbb525f23f73f4dfbc3691afd393ccf8))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Specs and Docs
|
|
18
|
+
|
|
19
|
+
* record PR 244 in spec 047 ([52fd1bd](https://github.com/pedronastasi/manychat-ai-agent/commit/52fd1bda1af595c7094a05aaa97c82213e84c585))
|
|
20
|
+
* specify a burst window before the model (spec 047) ([3351037](https://github.com/pedronastasi/manychat-ai-agent/commit/3351037174ab519bb7a9387c9a878db72e7747d9))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Dependencies
|
|
24
|
+
|
|
25
|
+
* **deps:** lock file maintenance ([5c22bf0](https://github.com/pedronastasi/manychat-ai-agent/commit/5c22bf0622de025e5ade1155b9f9dd1ebcbac90c))
|
|
26
|
+
* **deps:** lock file maintenance ([ca4b49b](https://github.com/pedronastasi/manychat-ai-agent/commit/ca4b49b3deee81c3951b4aa9ae80d24d73676ad2))
|
|
27
|
+
* **deps:** update pnpm to v12.14.0 ([6599f97](https://github.com/pedronastasi/manychat-ai-agent/commit/6599f97b7e27f4b41d1dced8e90c8a188db66c42))
|
|
28
|
+
* **deps:** update pnpm to v12.14.0 ([c68b87e](https://github.com/pedronastasi/manychat-ai-agent/commit/c68b87ee8e72786f201b372f037304cb387fa42b))
|
|
29
|
+
|
|
30
|
+
## [0.25.0](https://github.com/pedronastasi/manychat-ai-agent/compare/v0.24.1...v0.25.0) (2026-10-16)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
### Features
|
|
34
|
+
|
|
35
|
+
* a silent payment-link flow leaves the link to the reply ([b4d019e](https://github.com/pedronastasi/manychat-ai-agent/commit/b4d019e275fc0073acc82f1361eec0e59a68c163))
|
|
36
|
+
* a silent payment-link flow leaves the link to the reply ([11d3929](https://github.com/pedronastasi/manychat-ai-agent/commit/11d3929ae3c4e734d42fab62022dab33ac214e3b))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
### Bug Fixes
|
|
40
|
+
|
|
41
|
+
* an offering's link does not count as one it is a prefix of ([426ade9](https://github.com/pedronastasi/manychat-ai-agent/commit/426ade9850e3e72354697c9961218558ad8b6d05))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
### Specs and Docs
|
|
45
|
+
|
|
46
|
+
* **specs:** a silent payment flow leaves the link to the reply ([4611b15](https://github.com/pedronastasi/manychat-ai-agent/commit/4611b15744f4a81bd3cf6943a2a1f39644d2bb7f))
|
|
47
|
+
|
|
3
48
|
## [0.24.1](https://github.com/pedronastasi/manychat-ai-agent/compare/v0.24.0...v0.24.1) (2026-10-16)
|
|
4
49
|
|
|
5
50
|
|
|
@@ -54,8 +54,14 @@ export declare function escalationReply(reason: EscalationReason, message: strin
|
|
|
54
54
|
*
|
|
55
55
|
* `earlierReplies` is the text of the agent's earlier turns in the
|
|
56
56
|
* conversation, which the repetition check compares against (specs/013).
|
|
57
|
+
*
|
|
58
|
+
* `paymentLink` is the link the reply must carry because the turn sent a
|
|
59
|
+
* silent payment-link flow (specs/023 § A silent payment flow leaves the link
|
|
60
|
+
* to the reply).
|
|
57
61
|
*/
|
|
58
|
-
export declare function applyGuardrails(raw: unknown, rules: Rules, earlierReplies?: readonly string[]
|
|
62
|
+
export declare function applyGuardrails(raw: unknown, rules: Rules, earlierReplies?: readonly string[], options?: {
|
|
63
|
+
paymentLink?: string | undefined;
|
|
64
|
+
}): GuardedReply;
|
|
59
65
|
/**
|
|
60
66
|
* Flags prices that do not appear in the catalog.
|
|
61
67
|
*
|
package/dist/agent/guardrails.js
CHANGED
|
@@ -110,8 +110,12 @@ export function escalationReply(reason, message) {
|
|
|
110
110
|
*
|
|
111
111
|
* `earlierReplies` is the text of the agent's earlier turns in the
|
|
112
112
|
* conversation, which the repetition check compares against (specs/013).
|
|
113
|
+
*
|
|
114
|
+
* `paymentLink` is the link the reply must carry because the turn sent a
|
|
115
|
+
* silent payment-link flow (specs/023 § A silent payment flow leaves the link
|
|
116
|
+
* to the reply).
|
|
113
117
|
*/
|
|
114
|
-
export function applyGuardrails(raw, rules, earlierReplies = []) {
|
|
118
|
+
export function applyGuardrails(raw, rules, earlierReplies = [], options = {}) {
|
|
115
119
|
const interventions = [];
|
|
116
120
|
const parsed = AgentReply.safeParse(raw);
|
|
117
121
|
if (!parsed.success) {
|
|
@@ -200,12 +204,14 @@ export function applyGuardrails(raw, rules, earlierReplies = []) {
|
|
|
200
204
|
// matters is that exactly one question ends the turn, not which.
|
|
201
205
|
const bodyMessages = reply.messages;
|
|
202
206
|
const body = bodyMessages.at(-1);
|
|
207
|
+
let questionAppended = false;
|
|
203
208
|
if (reply.closing_question !== null && !reply.escalate) {
|
|
204
209
|
if (body !== undefined && endsWithQuestion(body)) {
|
|
205
210
|
interventions.push('closing_question_already_in_body');
|
|
206
211
|
}
|
|
207
212
|
else {
|
|
208
213
|
reply = { ...reply, messages: [...reply.messages, reply.closing_question] };
|
|
214
|
+
questionAppended = true;
|
|
209
215
|
}
|
|
210
216
|
}
|
|
211
217
|
// Verbatim repetition, most often an earlier answer replayed in reply to a
|
|
@@ -231,10 +237,59 @@ export function applyGuardrails(raw, rules, earlierReplies = []) {
|
|
|
231
237
|
// is nothing to ask and nothing to hand off.
|
|
232
238
|
interventions.push('duplicate_replaced');
|
|
233
239
|
reply = { ...reply, messages: [closer], closing_question: null };
|
|
240
|
+
questionAppended = false;
|
|
234
241
|
}
|
|
235
242
|
}
|
|
243
|
+
// A silent payment-link flow sent nothing, so a reply without the link
|
|
244
|
+
// leaves the contact recorded at link_sent with no way to pay (specs/023
|
|
245
|
+
// § A silent payment flow leaves the link to the reply). Checked last, on
|
|
246
|
+
// what would be delivered. The link goes on its own line at the end of the
|
|
247
|
+
// body, so the turn still ends on its one question. Skipped on an
|
|
248
|
+
// escalation: the person taking over sends it.
|
|
249
|
+
const link = options.paymentLink;
|
|
250
|
+
if (link !== undefined && !reply.escalate && !carriesLink(reply.messages, link)) {
|
|
251
|
+
interventions.push('payment_link_added');
|
|
252
|
+
reply = { ...reply, messages: withLink(reply.messages, link, questionAppended) };
|
|
253
|
+
}
|
|
236
254
|
return { reply, interventions, ...(escalatedBy ? { escalatedBy } : {}) };
|
|
237
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* The messages with the link on its own line after the body's last one, or as
|
|
258
|
+
* a message of its own ahead of a question the body ends on or that would not
|
|
259
|
+
* fit beside it.
|
|
260
|
+
*/
|
|
261
|
+
function withLink(messages, link, questionAppended) {
|
|
262
|
+
const last = messages.length - 1;
|
|
263
|
+
const asked = questionAppended || endsWithQuestion(messages[last] ?? '');
|
|
264
|
+
const at = asked ? last - 1 : last;
|
|
265
|
+
const target = messages[at];
|
|
266
|
+
if (target === undefined || target.length + link.length + 1 > MAX_MESSAGE_CHARS) {
|
|
267
|
+
return messages.toSpliced(at + 1, 0, link);
|
|
268
|
+
}
|
|
269
|
+
return messages.with(at, `${target}\n${link}`);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Whether any message carries the link, as the catalog writes it or with its
|
|
273
|
+
* percent-encoding undone, which is how a model often copies a URL with an
|
|
274
|
+
* accented path. A trailing slash is optional.
|
|
275
|
+
*
|
|
276
|
+
* The link must end where the URL in the message ends: another offering's
|
|
277
|
+
* link that starts with this one (`…/foundation-plus` for `…/foundation`) is
|
|
278
|
+
* not this link, and counting it would leave the contact with the wrong one.
|
|
279
|
+
*/
|
|
280
|
+
function carriesLink(messages, link) {
|
|
281
|
+
const bare = (url) => url.replace(/\/+$/, '');
|
|
282
|
+
let decoded = link;
|
|
283
|
+
try {
|
|
284
|
+
decoded = decodeURI(link);
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
// A malformed escape: only the link as written can match.
|
|
288
|
+
}
|
|
289
|
+
const patterns = [...new Set([bare(link), bare(decoded)])].map(form => new RegExp(`${escapeRegExp(form)}/?(?![\\p{L}\\p{N}_%/-])`, 'u'));
|
|
290
|
+
return messages.some(message => patterns.some(pattern => pattern.test(message)));
|
|
291
|
+
}
|
|
292
|
+
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
238
293
|
/** Numbers with a currency cue nearby, e.g. "$45.000", "45000 pesos". */
|
|
239
294
|
const PRICE_PATTERN = /(?:\$\s?)([\d][\d.,]{2,})|([\d][\d.,]{2,})\s?(?:pesos|ars|usd)/gi;
|
|
240
295
|
/** Digits only, so "45.000", "45,000" and "45000" compare equal. */
|
package/dist/agent/prompt.d.ts
CHANGED
|
@@ -99,6 +99,12 @@ opening?: {
|
|
|
99
99
|
reads?: readonly {
|
|
100
100
|
tool: string;
|
|
101
101
|
result: ReadResult;
|
|
102
|
+
}[],
|
|
103
|
+
/** The tenant's flows, for which of them send the contact nothing (specs/023). */
|
|
104
|
+
flows?: readonly {
|
|
105
|
+
id: string;
|
|
106
|
+
role?: string | undefined;
|
|
107
|
+
silent?: boolean | undefined;
|
|
102
108
|
}[]): string;
|
|
103
109
|
/**
|
|
104
110
|
* The server's note on an earlier turn of what it performed, or null when it
|
package/dist/agent/prompt.js
CHANGED
|
@@ -409,7 +409,16 @@ function salesSection(tools) {
|
|
|
409
409
|
'moves forward: a write to an earlier stage is refused.',
|
|
410
410
|
...(link
|
|
411
411
|
? [
|
|
412
|
-
|
|
412
|
+
...(link.silent
|
|
413
|
+
? [
|
|
414
|
+
`The payment link is recorded by the flow ${link.id}, which is silent: it sends the`,
|
|
415
|
+
'contact nothing. Every time you send it, write the link of the contact’s offering from',
|
|
416
|
+
'the CATALOG in the same reply: the flow does not deliver it. It is refused before',
|
|
417
|
+
'prepared, unless the contact',
|
|
418
|
+
]
|
|
419
|
+
: [
|
|
420
|
+
`The payment link is the flow ${link.id}. It is refused before prepared, unless the contact`,
|
|
421
|
+
]),
|
|
413
422
|
'asks for it: a contact whose message asks for the link, the payment methods or how to pay',
|
|
414
423
|
'gets it, qualified or not, and you pass contactAsked true. Never pass it otherwise.',
|
|
415
424
|
]
|
|
@@ -528,15 +537,35 @@ contact,
|
|
|
528
537
|
/** The opening the turn's prospect write queued, if it did (specs/034). */
|
|
529
538
|
opening,
|
|
530
539
|
/** Each plugin read tool's last successful read, its text still fenced (specs/039). */
|
|
531
|
-
reads = []
|
|
540
|
+
reads = [],
|
|
541
|
+
/** The tenant's flows, for which of them send the contact nothing (specs/023). */
|
|
542
|
+
flows = []) {
|
|
532
543
|
// Flows only: a stage write sent ahead of a payment link is not news to the contact.
|
|
533
544
|
const sent = (stage.sent ?? []).filter(entry => entry.action.tool === 'send_flow');
|
|
534
|
-
const
|
|
545
|
+
const flowOf = (entry) => flows.find(flow => flow.id === entry.action.id);
|
|
546
|
+
const performed = sent.filter(entry => entry.records[0]?.status === 'performed');
|
|
547
|
+
const went = performed.filter(entry => !flowOf(entry)?.silent);
|
|
548
|
+
// A silent flow is not something the contact received, and a reply that
|
|
549
|
+
// treats it as one leaves out what it stands for (specs/023 § A silent
|
|
550
|
+
// payment flow leaves the link to the reply).
|
|
551
|
+
const recorded = performed.filter(entry => flowOf(entry)?.silent);
|
|
552
|
+
const silentLink = (action) => {
|
|
553
|
+
const flow = flows.find(entry => entry.id === action.id);
|
|
554
|
+
return action.tool === 'send_flow' && flow?.silent === true && flow.role === 'payment_link';
|
|
555
|
+
};
|
|
556
|
+
const link = recorded.some(entry => silentLink(entry.action));
|
|
557
|
+
const stagedLink = stage.staged.some(silentLink);
|
|
535
558
|
const failed = sent.filter(entry => entry.records[0]?.status !== 'performed');
|
|
536
559
|
const lines = [
|
|
537
560
|
went.length > 0
|
|
538
561
|
? `SENT: Already sent to the contact, who receives it before your reply: ${went.map(entry => describeAction(entry.action)).join(', ')}. Your reply follows it; do not repeat what it contains.`
|
|
539
562
|
: null,
|
|
563
|
+
recorded.length > 0
|
|
564
|
+
? `RECORDED: ${recorded.map(entry => describeAction(entry.action)).join(', ')}. Silent: the contact received nothing from it. Write your reply in full, as if it had not been sent.` +
|
|
565
|
+
(link
|
|
566
|
+
? ' The payment link is yours to write: put the link of the contact’s offering from the CATALOG in this reply.'
|
|
567
|
+
: '')
|
|
568
|
+
: null,
|
|
540
569
|
failed.length > 0
|
|
541
570
|
? `NOT SENT: ManyChat refused: ${failed.map(entry => describeAction(entry.action)).join(', ')}. Do not say it was sent.`
|
|
542
571
|
: null,
|
|
@@ -545,6 +574,9 @@ reads = []) {
|
|
|
545
574
|
: sent.length > 0
|
|
546
575
|
? null
|
|
547
576
|
: 'ACTIONS: None of your tool calls were staged.',
|
|
577
|
+
stagedLink
|
|
578
|
+
? 'The payment link flow is silent and sends the contact nothing: put the link of the contact’s offering from the CATALOG in this reply.'
|
|
579
|
+
: null,
|
|
548
580
|
stage.dropped.length > 0
|
|
549
581
|
? `Not staged, over the limit of ${MAX_ACTIONS_PER_TURN} per turn: ${stage.dropped.map(describeAction).join(', ')}.`
|
|
550
582
|
: null,
|
package/dist/agent/runner.js
CHANGED
|
@@ -4,7 +4,7 @@ import { NO_TOOLS, offersTools } from "../contracts/config.js";
|
|
|
4
4
|
import { actionsNote, buildSystemPrompt, offeringNotice, fenceUserText, funnelNotice, mediaNotice, intentNotice, nudgeNotice, stagedNotice, } from "./prompt.js";
|
|
5
5
|
import { applyGuardrails, escalationReply } from "./guardrails.js";
|
|
6
6
|
import { estimateCostUsd, supportsTemperature } from "./registry.js";
|
|
7
|
-
import { ActionStage, buildTools, offeringField, funnelField, intentField, MAX_STEPS, openingFlow, stagesProspect, } from "./tools.js";
|
|
7
|
+
import { ActionStage, buildTools, offeringField, funnelField, intentField, MAX_STEPS, openingFlow, silentPaymentLink, stagesProspect, } from "./tools.js";
|
|
8
8
|
import { renderPlaybook } from "../learning/playbook.js";
|
|
9
9
|
/**
|
|
10
10
|
* An earlier turn as the model reads it. An agent turn is followed by the
|
|
@@ -171,7 +171,7 @@ export class GenerateTextRunner {
|
|
|
171
171
|
...messages,
|
|
172
172
|
{
|
|
173
173
|
role: 'user',
|
|
174
|
-
content: stagedNotice(stage, reads?.latest, opening && stagesProspect(stage, tenantTools) ? opening : undefined, pluginReads?.latest),
|
|
174
|
+
content: stagedNotice(stage, reads?.latest, opening && stagesProspect(stage, tenantTools) ? opening : undefined, pluginReads?.latest, tenantTools.flows),
|
|
175
175
|
},
|
|
176
176
|
],
|
|
177
177
|
}
|
|
@@ -242,7 +242,21 @@ export class GenerateTextRunner {
|
|
|
242
242
|
};
|
|
243
243
|
}
|
|
244
244
|
const earlierReplies = history.filter(turn => turn.role === 'agent').map(turn => turn.text);
|
|
245
|
-
const
|
|
245
|
+
const paymentLink = silentPaymentLink(tenantTools, config.catalog, stage, contact);
|
|
246
|
+
let guarded = applyGuardrails(output, config.rules, earlierReplies, {
|
|
247
|
+
paymentLink: paymentLink ?? undefined,
|
|
248
|
+
});
|
|
249
|
+
// Sent, with no link to put in the reply: the loader's checks leave only a
|
|
250
|
+
// contact with no offering recorded. A reply without the link would leave
|
|
251
|
+
// them at link_sent with no way to pay, so a person finishes the turn
|
|
252
|
+
// (specs/023 § A silent payment flow leaves the link to the reply, C6).
|
|
253
|
+
if (paymentLink === null && !guarded.reply.escalate) {
|
|
254
|
+
guarded = {
|
|
255
|
+
reply: escalationReply('low_confidence', config.rules.messages.escalation),
|
|
256
|
+
interventions: [...guarded.interventions, 'payment_link_unknown'],
|
|
257
|
+
escalatedBy: 'error',
|
|
258
|
+
};
|
|
259
|
+
}
|
|
246
260
|
return {
|
|
247
261
|
reply: guarded.reply,
|
|
248
262
|
interventions: guarded.interventions,
|
package/dist/agent/tools.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ToolSet } from 'ai';
|
|
2
|
-
import type { Tools, INTENT_VALUES } from '../contracts/config.ts';
|
|
2
|
+
import type { Catalog, 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';
|
|
@@ -122,7 +122,22 @@ export declare function paymentLinkFlow(config: Tools): {
|
|
|
122
122
|
onStage?: "qualifying" | "nurturing" | "offered" | "prepared" | undefined;
|
|
123
123
|
offering?: string | undefined;
|
|
124
124
|
settleSeconds?: number | undefined;
|
|
125
|
+
silent?: boolean | undefined;
|
|
125
126
|
} | undefined;
|
|
127
|
+
/**
|
|
128
|
+
* The link a reply must carry because the turn sent a silent payment-link
|
|
129
|
+
* flow, which leaves the link to the reply (specs/023 § A silent payment flow
|
|
130
|
+
* leaves the link to the reply). `undefined` when the turn neither sent such
|
|
131
|
+
* a flow nor staged it to be sent after the reply.
|
|
132
|
+
*
|
|
133
|
+
* The link is the catalog `url` of the turn's offering: one written this turn,
|
|
134
|
+
* else the contact's, else the catalog's only offering. `null` when the flow
|
|
135
|
+
* went out and no offering or link is known, which the loader's checks leave
|
|
136
|
+
* to a contact with no offering recorded yet.
|
|
137
|
+
*/
|
|
138
|
+
export declare function silentPaymentLink(config: Tools, catalog: Catalog, stage: ActionStage, contact: {
|
|
139
|
+
offering?: string | undefined;
|
|
140
|
+
} | undefined): string | null | undefined;
|
|
126
141
|
/** The flow the server sends on a contact's prospect turn, if the tenant marked one (specs/034). */
|
|
127
142
|
export declare function openingFlow(config: Tools): {
|
|
128
143
|
id: string;
|
|
@@ -133,6 +148,7 @@ export declare function openingFlow(config: Tools): {
|
|
|
133
148
|
onStage?: "qualifying" | "nurturing" | "offered" | "prepared" | undefined;
|
|
134
149
|
offering?: string | undefined;
|
|
135
150
|
settleSeconds?: number | undefined;
|
|
151
|
+
silent?: boolean | undefined;
|
|
136
152
|
} | undefined;
|
|
137
153
|
/** The field the contact's offering is kept in, if the tenant marked one (specs/028). */
|
|
138
154
|
export declare function offeringField(config: Tools): {
|
package/dist/agent/tools.js
CHANGED
|
@@ -192,6 +192,33 @@ export function funnelField(config) {
|
|
|
192
192
|
export function paymentLinkFlow(config) {
|
|
193
193
|
return config.flows.find(flow => flow.role === 'payment_link');
|
|
194
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* The link a reply must carry because the turn sent a silent payment-link
|
|
197
|
+
* flow, which leaves the link to the reply (specs/023 § A silent payment flow
|
|
198
|
+
* leaves the link to the reply). `undefined` when the turn neither sent such
|
|
199
|
+
* a flow nor staged it to be sent after the reply.
|
|
200
|
+
*
|
|
201
|
+
* The link is the catalog `url` of the turn's offering: one written this turn,
|
|
202
|
+
* else the contact's, else the catalog's only offering. `null` when the flow
|
|
203
|
+
* went out and no offering or link is known, which the loader's checks leave
|
|
204
|
+
* to a contact with no offering recorded yet.
|
|
205
|
+
*/
|
|
206
|
+
export function silentPaymentLink(config, catalog, stage, contact) {
|
|
207
|
+
const link = paymentLinkFlow(config);
|
|
208
|
+
if (!link?.silent)
|
|
209
|
+
return undefined;
|
|
210
|
+
const sent = stage.sent.some(entry => entry.action.tool === 'send_flow' &&
|
|
211
|
+
entry.action.id === link.id &&
|
|
212
|
+
entry.records[0]?.status === 'performed');
|
|
213
|
+
const staged = stage.staged.some(action => action.tool === 'send_flow' && action.id === link.id);
|
|
214
|
+
if (!sent && !staged)
|
|
215
|
+
return undefined;
|
|
216
|
+
const written = stage.staged.findLast((action) => action.tool === 'set_field' && action.offering === true);
|
|
217
|
+
const id = written?.value ??
|
|
218
|
+
contact?.offering ??
|
|
219
|
+
(catalog.offerings.length === 1 ? catalog.offerings[0].id : undefined);
|
|
220
|
+
return catalog.offerings.find(offering => offering.id === id)?.url ?? null;
|
|
221
|
+
}
|
|
195
222
|
/** The flow the server sends on a contact's prospect turn, if the tenant marked one (specs/034). */
|
|
196
223
|
export function openingFlow(config) {
|
|
197
224
|
return config.flows.find(flow => flow.role === 'opening');
|
|
@@ -299,12 +326,18 @@ const catalogOf = (entries) => entries
|
|
|
299
326
|
const notes = [
|
|
300
327
|
entry.offering === undefined ? null : `offering ${entry.offering}`,
|
|
301
328
|
entry.onStage === undefined ? null : `sent by the system when you record ${entry.onStage}`,
|
|
329
|
+
entry.silent ? 'silent: sends the contact nothing' : null,
|
|
302
330
|
].filter(Boolean);
|
|
303
331
|
return `- ${entry.id}${notes.length > 0 ? ` (${notes.join('; ')})` : ''}: ${entry.description}`;
|
|
304
332
|
})
|
|
305
333
|
.join('\n');
|
|
306
334
|
const STAGED = 'The action is staged, not performed: it happens after your reply is sent, and not at all if the turn escalates.';
|
|
307
335
|
const SENT_NOW = 'The flow is sent when you call this, before your reply: the contact receives it first and your reply follows it. Do not repeat what it contains. The result says whether it went out.';
|
|
336
|
+
const SILENT = 'A silent flow sends the contact nothing: write your reply in full, as if it had not been sent.';
|
|
337
|
+
/** The note a silent flow's result carries; the payment link's names the link. */
|
|
338
|
+
const silentResult = (role) => role === 'payment_link'
|
|
339
|
+
? 'The contact received nothing from this flow. Your reply must carry the link of the contact’s offering from the CATALOG.'
|
|
340
|
+
: 'The contact received nothing from this flow. Write your reply in full, as if it had not been sent.';
|
|
308
341
|
const CONTACT_ASKED = 'The payment link is refused before the stage is prepared. Pass contactAsked: true only when the contact’s message this turn asks for the link, the payment methods or how to pay; it is recorded.';
|
|
309
342
|
const STAGED_NOTE = 'The note is staged, not written: it is written after your reply is sent. It replaces what the note held, so read it first with get_contact to add to it.';
|
|
310
343
|
/**
|
|
@@ -419,6 +452,7 @@ options = {}) {
|
|
|
419
452
|
const gated = funnel !== undefined && unsent.some(flow => flow.role === 'payment_link');
|
|
420
453
|
tools.send_flow = tool({
|
|
421
454
|
description: `Send the contact one of these flows.\n${listing}\n${sends ? SENT_NOW : STAGED}` +
|
|
455
|
+
(unsent.some(flow => flow.silent) ? `\n${SILENT}` : '') +
|
|
422
456
|
(gated ? `\n${CONTACT_ASKED}` : ''),
|
|
423
457
|
inputSchema: gated
|
|
424
458
|
? z.object({ flow: z.enum(idsOf(unsent)), contactAsked: z.boolean().optional() })
|
|
@@ -466,8 +500,14 @@ options = {}) {
|
|
|
466
500
|
...(followOn ? { followOn } : {}),
|
|
467
501
|
...(asked ? { contactAsked: true } : {}),
|
|
468
502
|
};
|
|
469
|
-
|
|
470
|
-
|
|
503
|
+
// What a silent flow leaves to the reply, said where the model reads
|
|
504
|
+
// the outcome (specs/023 § A silent payment flow leaves the link to
|
|
505
|
+
// the reply).
|
|
506
|
+
const silent = entry.silent ? { silent: silentResult(entry.role) } : {};
|
|
507
|
+
if (!sends) {
|
|
508
|
+
const staged = stage.stage(action);
|
|
509
|
+
return staged ? { staged, ...silent } : { staged };
|
|
510
|
+
}
|
|
471
511
|
// The opening goes first on the prospect turn (specs/034). A payment
|
|
472
512
|
// link is counted at link_sent before the await, so a funnel or offering
|
|
473
513
|
// write made beside it is refused rather than landing after it (specs/029).
|
|
@@ -490,7 +530,9 @@ options = {}) {
|
|
|
490
530
|
? stage.staged.filter(staged => staged.tool === 'set_field' && staged.id === funnel.id)
|
|
491
531
|
: [];
|
|
492
532
|
const sent = await stage.send(action, sends, first);
|
|
493
|
-
|
|
533
|
+
if (sent === undefined)
|
|
534
|
+
return { sent: false, reason: 'over the per-turn limit' };
|
|
535
|
+
return sent ? { sent, ...silent } : { sent };
|
|
494
536
|
},
|
|
495
537
|
});
|
|
496
538
|
}
|
package/dist/config/loader.js
CHANGED
|
@@ -84,6 +84,22 @@ function offeringMismatches(tools, catalog) {
|
|
|
84
84
|
: tools.flows
|
|
85
85
|
.filter(flow => flow.offering !== undefined)
|
|
86
86
|
.map(flow => `flow '${flow.id}' has an offering, but no field is marked "offering"`)),
|
|
87
|
+
// A silent payment flow leaves the link to the reply, and the server puts
|
|
88
|
+
// the contact's offering's link there when the reply leaves it out, so
|
|
89
|
+
// that link must exist and the offering must be knowable (specs/023
|
|
90
|
+
// § A silent payment flow leaves the link to the reply).
|
|
91
|
+
...tools.flows
|
|
92
|
+
.filter(flow => flow.role === 'payment_link' && flow.silent)
|
|
93
|
+
.flatMap(flow => [
|
|
94
|
+
...catalog.offerings
|
|
95
|
+
.filter(offering => offering.url === null)
|
|
96
|
+
.map(offering => `flow '${flow.id}' is a silent payment link, so offering '${offering.id}' needs a url`),
|
|
97
|
+
...(field || ids.length === 1
|
|
98
|
+
? []
|
|
99
|
+
: [
|
|
100
|
+
`flow '${flow.id}' is a silent payment link, so with several offerings a field must be marked "offering"`,
|
|
101
|
+
]),
|
|
102
|
+
]),
|
|
87
103
|
];
|
|
88
104
|
}
|
|
89
105
|
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
@@ -85,6 +85,8 @@ export declare const LearningSchema: z.ZodObject<{
|
|
|
85
85
|
maxRunCostUsd: z.ZodNumber;
|
|
86
86
|
}, z.core.$strict>;
|
|
87
87
|
export type Learning = z.infer<typeof LearningSchema>;
|
|
88
|
+
/** The longest `burstWindowMs`: it leaves the model most of the race (specs/047). */
|
|
89
|
+
export declare const BURST_WINDOW_MAX_MS = 4000;
|
|
88
90
|
export declare const RulesSchema: z.ZodObject<{
|
|
89
91
|
messages: z.ZodObject<{
|
|
90
92
|
acknowledgement: z.ZodString;
|
|
@@ -109,6 +111,7 @@ export declare const RulesSchema: z.ZodObject<{
|
|
|
109
111
|
turnsPerSubscriberPerHour: z.ZodDefault<z.ZodNumber>;
|
|
110
112
|
}, z.core.$strip>;
|
|
111
113
|
foldBursts: z.ZodDefault<z.ZodBoolean>;
|
|
114
|
+
burstWindowMs: z.ZodDefault<z.ZodNumber>;
|
|
112
115
|
learning: z.ZodOptional<z.ZodObject<{
|
|
113
116
|
language: z.ZodString;
|
|
114
117
|
convertedTag: z.ZodString;
|
|
@@ -186,6 +189,7 @@ export declare const ToolsSchema: z.ZodObject<{
|
|
|
186
189
|
}> & z.ZodType<"qualifying" | "nurturing" | "offered" | "prepared", "new" | "qualifying" | "nurturing" | "offered" | "prepared" | "link_sent", z.core.$ZodTypeInternals<"qualifying" | "nurturing" | "offered" | "prepared", "new" | "qualifying" | "nurturing" | "offered" | "prepared" | "link_sent">>>;
|
|
187
190
|
offering: z.ZodOptional<z.ZodString>;
|
|
188
191
|
settleSeconds: z.ZodOptional<z.ZodNumber>;
|
|
192
|
+
silent: z.ZodOptional<z.ZodBoolean>;
|
|
189
193
|
}, z.core.$strip>>>;
|
|
190
194
|
tags: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
191
195
|
id: z.ZodString;
|
package/dist/contracts/config.js
CHANGED
|
@@ -133,6 +133,8 @@ export const LearningSchema = z
|
|
|
133
133
|
maxRunCostUsd: z.number().positive(),
|
|
134
134
|
})
|
|
135
135
|
.strict();
|
|
136
|
+
/** The longest `burstWindowMs`: it leaves the model most of the race (specs/047). */
|
|
137
|
+
export const BURST_WINDOW_MAX_MS = 4_000;
|
|
136
138
|
export const RulesSchema = z.object({
|
|
137
139
|
messages: MessagesSchema,
|
|
138
140
|
confidenceThreshold: z.number().min(0).max(1).default(0.6),
|
|
@@ -172,6 +174,13 @@ export const RulesSchema = z.object({
|
|
|
172
174
|
* Bubbles that wait behind a running turn are folded whatever this says.
|
|
173
175
|
*/
|
|
174
176
|
foldBursts: z.boolean().default(false),
|
|
177
|
+
/**
|
|
178
|
+
* How long a turn waits for the contact to write again before it calls the
|
|
179
|
+
* model (specs/047). A bubble that arrives in that time folds this turn's
|
|
180
|
+
* message into its own. The wait counts against the race deadline (C7), so
|
|
181
|
+
* it is capped well inside it. 0, the default, waits for nothing.
|
|
182
|
+
*/
|
|
183
|
+
burstWindowMs: z.number().int().min(0).max(BURST_WINDOW_MAX_MS).default(0),
|
|
175
184
|
/**
|
|
176
185
|
* Learning from outcomes (specs/031). Absent, no learning job runs, no
|
|
177
186
|
* playbook is loaded, and the prompt is what it was before that spec.
|
|
@@ -315,8 +324,9 @@ export const ToolsSchema = z
|
|
|
315
324
|
repeatable: z.boolean().optional(),
|
|
316
325
|
/**
|
|
317
326
|
* `payment_link`: performing it writes the funnel field to `link_sent`
|
|
318
|
-
* (specs/023)
|
|
319
|
-
*
|
|
327
|
+
* (specs/023); with `silent`, the reply carries the link. `opening`:
|
|
328
|
+
* the server sends it on the contact's first model turn, and the
|
|
329
|
+
* model never does (specs/032).
|
|
320
330
|
*/
|
|
321
331
|
role: z.enum(['payment_link', 'opening']).optional(),
|
|
322
332
|
/**
|
|
@@ -338,9 +348,20 @@ export const ToolsSchema = z
|
|
|
338
348
|
* (specs/030). Absent, it does not wait.
|
|
339
349
|
*/
|
|
340
350
|
settleSeconds: z.number().int().min(0).max(MAX_SETTLE_SECONDS).optional(),
|
|
351
|
+
/**
|
|
352
|
+
* The flow sends the contact no message: it only records something
|
|
353
|
+
* in ManyChat, such as a tracking event. On the payment-link flow it
|
|
354
|
+
* means the reply carries the link (specs/023 § A silent payment
|
|
355
|
+
* flow leaves the link to the reply).
|
|
356
|
+
*/
|
|
357
|
+
silent: z.boolean().optional(),
|
|
341
358
|
}))
|
|
342
359
|
.default([])
|
|
343
360
|
.refine(uniqueIds, 'flow ids must be unique')
|
|
361
|
+
// Nothing plays, so there is nothing for the reply to wait for (specs/030).
|
|
362
|
+
.refine(flows => !flows.some(flow => flow.silent && flow.settleSeconds !== undefined), 'a "silent" flow may not have "settleSeconds"')
|
|
363
|
+
// The opening is the contact's first message from the tenant (specs/032).
|
|
364
|
+
.refine(flows => !flows.some(flow => flow.silent && flow.role === 'opening'), 'the "opening" flow may not be "silent"')
|
|
344
365
|
.refine(flows => flows.filter(flow => flow.role === 'payment_link').length <= 1, 'only one flow may have role "payment_link"')
|
|
345
366
|
// One payment flow serves every offering and branches on the offering
|
|
346
367
|
// field inside ManyChat (specs/028 § One payment flow serves every offering).
|
|
@@ -18,6 +18,11 @@ export interface TurnSlot {
|
|
|
18
18
|
* yet: the contact kept writing, and that turn answers them (specs/046).
|
|
19
19
|
*/
|
|
20
20
|
followed: () => boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Settles with `true` as soon as this turn is followed, or with `false` once
|
|
23
|
+
* `ms` have passed without it: the contact has gone quiet (specs/047).
|
|
24
|
+
*/
|
|
25
|
+
quiet: (ms: number) => Promise<boolean>;
|
|
21
26
|
}
|
|
22
27
|
/**
|
|
23
28
|
* One turn at a time per contact, in the order their requests arrived
|
|
@@ -28,6 +28,11 @@ export class TurnLanes {
|
|
|
28
28
|
const places = this.places.get(contact) ?? [];
|
|
29
29
|
places.push(place);
|
|
30
30
|
this.places.set(contact, places);
|
|
31
|
+
const followed = () => {
|
|
32
|
+
const current = this.places.get(contact) ?? [];
|
|
33
|
+
const index = current.indexOf(place);
|
|
34
|
+
return index >= 0 && current.slice(index + 1).some(later => later.claimed);
|
|
35
|
+
};
|
|
31
36
|
const gone = () => {
|
|
32
37
|
const current = this.places.get(contact);
|
|
33
38
|
if (!current)
|
|
@@ -47,12 +52,24 @@ export class TurnLanes {
|
|
|
47
52
|
},
|
|
48
53
|
claim: () => {
|
|
49
54
|
place.claimed = true;
|
|
50
|
-
},
|
|
51
|
-
followed: () => {
|
|
52
55
|
const current = this.places.get(contact) ?? [];
|
|
53
|
-
const
|
|
54
|
-
|
|
56
|
+
for (const earlier of current.slice(0, current.indexOf(place)))
|
|
57
|
+
earlier.wake?.();
|
|
55
58
|
},
|
|
59
|
+
followed,
|
|
60
|
+
quiet: ms => new Promise(resolve => {
|
|
61
|
+
if (followed())
|
|
62
|
+
return resolve(true);
|
|
63
|
+
const timer = setTimeout(() => {
|
|
64
|
+
place.wake = undefined;
|
|
65
|
+
resolve(false);
|
|
66
|
+
}, ms);
|
|
67
|
+
place.wake = () => {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
place.wake = undefined;
|
|
70
|
+
resolve(true);
|
|
71
|
+
};
|
|
72
|
+
}),
|
|
56
73
|
};
|
|
57
74
|
}
|
|
58
75
|
bounded(before) {
|
package/dist/routes/turn.js
CHANGED
|
@@ -150,58 +150,67 @@ export class TurnHandler {
|
|
|
150
150
|
};
|
|
151
151
|
const leave = () => void background.then(slot.leave, slot.leave);
|
|
152
152
|
const expired = () => logger.error({ conversation: known?.id }, 'turn wait expired');
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
logger.error({ conversation: known?.id, err: String(error) }, 'waiting turn failed');
|
|
173
|
-
await this.handOffSilent(inbound);
|
|
174
|
-
})
|
|
175
|
-
.finally(leave);
|
|
176
|
-
// The previous turn has started the conversation by now; its reply,
|
|
177
|
-
// or its holding line, is the contact's answer for the moment.
|
|
178
|
-
const current = known ?? (await this.store.find(inbound.tenantId, inbound.subscriberId));
|
|
179
|
-
logger.info({ conversation: current?.id }, 'turn waits for the previous one');
|
|
180
|
-
return {
|
|
181
|
-
reply: {
|
|
182
|
-
messages: [],
|
|
183
|
-
escalate: false,
|
|
184
|
-
escalation_reason: null,
|
|
185
|
-
confidence: 1,
|
|
186
|
-
closing_question: null,
|
|
187
|
-
},
|
|
188
|
-
outcome: 'deferred',
|
|
189
|
-
conversationId: current?.id ?? '',
|
|
190
|
-
binding,
|
|
191
|
-
silent: true,
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
if (waited === 'expired')
|
|
153
|
+
/**
|
|
154
|
+
* Whether this turn only records its message, which a later turn of the
|
|
155
|
+
* contact answers (specs/046): the contact wrote again while it waited for
|
|
156
|
+
* their previous turn, or, with a burst window, before that window passed
|
|
157
|
+
* in silence (specs/047). Only a turn that reads history folds: any other
|
|
158
|
+
* runs, and is bound by the token state it finds then (specs/019).
|
|
159
|
+
*/
|
|
160
|
+
const folds = async () => {
|
|
161
|
+
if (!readsHistory || !this.foldable(inbound))
|
|
162
|
+
return false;
|
|
163
|
+
if (slot.waited && slot.followed())
|
|
164
|
+
return true;
|
|
165
|
+
const windowMs = this.deps.rules.burstWindowMs;
|
|
166
|
+
return windowMs > 0 && (await slot.quiet(windowMs));
|
|
167
|
+
};
|
|
168
|
+
// The wait for the previous turn and the burst window both count against
|
|
169
|
+
// the deadline (C7): a turn still deciding at it is answered silently now.
|
|
170
|
+
const decided = (async () => {
|
|
171
|
+
if (slot.waited && (await slot.ready) === 'expired')
|
|
195
172
|
expired();
|
|
173
|
+
return (await folds()) ? 'fold' : 'run';
|
|
174
|
+
})();
|
|
175
|
+
let timer;
|
|
176
|
+
const late = new Promise(resolve => {
|
|
177
|
+
timer = setTimeout(() => resolve('late'), Math.max(0, deadlineAt - Date.now()));
|
|
178
|
+
});
|
|
179
|
+
const first = await Promise.race([decided, late]).finally(() => clearTimeout(timer));
|
|
180
|
+
if (first === 'late') {
|
|
181
|
+
void decided
|
|
182
|
+
.then(async (decision) => {
|
|
183
|
+
if (decision === 'fold')
|
|
184
|
+
await this.fold(inbound, binding);
|
|
185
|
+
else
|
|
186
|
+
await this.run(inbound, { ...opts, late: true });
|
|
187
|
+
})
|
|
188
|
+
.catch(async (error) => {
|
|
189
|
+
logger.error({ conversation: known?.id, err: String(error) }, 'waiting turn failed');
|
|
190
|
+
await this.handOffSilent(inbound);
|
|
191
|
+
})
|
|
192
|
+
.finally(leave);
|
|
193
|
+
// The previous turn has started the conversation by now; its reply,
|
|
194
|
+
// or its holding line, is the contact's answer for the moment.
|
|
195
|
+
const current = known ?? (await this.store.find(inbound.tenantId, inbound.subscriberId));
|
|
196
|
+
logger.info({ conversation: current?.id }, 'turn answered silently while it waits');
|
|
197
|
+
return {
|
|
198
|
+
reply: {
|
|
199
|
+
messages: [],
|
|
200
|
+
escalate: false,
|
|
201
|
+
escalation_reason: null,
|
|
202
|
+
confidence: 1,
|
|
203
|
+
closing_question: null,
|
|
204
|
+
},
|
|
205
|
+
outcome: 'deferred',
|
|
206
|
+
conversationId: current?.id ?? '',
|
|
207
|
+
binding,
|
|
208
|
+
silent: true,
|
|
209
|
+
};
|
|
196
210
|
}
|
|
197
211
|
try {
|
|
198
|
-
|
|
199
|
-
// answers both, so this one only records it (specs/046). Only a turn
|
|
200
|
-
// that reads history folds: any other runs, and is bound by the token
|
|
201
|
-
// state it finds then (specs/019).
|
|
202
|
-
if (readsHistory && slot.waited && slot.followed() && this.foldable(inbound)) {
|
|
212
|
+
if (first === 'fold')
|
|
203
213
|
return await this.fold(inbound, binding);
|
|
204
|
-
}
|
|
205
214
|
return await this.run(inbound, opts);
|
|
206
215
|
}
|
|
207
216
|
finally {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "manychat-ai-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
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": {
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"engines": {
|
|
37
37
|
"node": ">=22"
|
|
38
38
|
},
|
|
39
|
-
"packageManager": "pnpm@12.
|
|
39
|
+
"packageManager": "pnpm@12.14.0",
|
|
40
40
|
"scripts": {
|
|
41
41
|
"dev": "node --experimental-strip-types --disable-warning=ExperimentalWarning --env-file=.env --watch src/main.ts",
|
|
42
42
|
"bootstrap": "node scripts/setup.mjs",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"@ai-sdk/anthropic": "^4.0.53",
|
|
72
72
|
"@ai-sdk/google": "^4.0.69",
|
|
73
73
|
"@ai-sdk/openai": "^4.0.66",
|
|
74
|
-
"@ai-sdk/provider": "4.0.
|
|
74
|
+
"@ai-sdk/provider": "4.0.29",
|
|
75
75
|
"@fastify/helmet": "^13.1.1",
|
|
76
76
|
"@fastify/rate-limit": "^11.2.0",
|
|
77
77
|
"@fastify/swagger": "^9.8.1",
|