manychat-ai-agent 0.24.1 → 0.25.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 +18 -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 +1 -0
- package/dist/contracts/config.js +14 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.25.0](https://github.com/pedronastasi/manychat-ai-agent/compare/v0.24.1...v0.25.0) (2026-10-16)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* a silent payment-link flow leaves the link to the reply ([b4d019e](https://github.com/pedronastasi/manychat-ai-agent/commit/b4d019e275fc0073acc82f1361eec0e59a68c163))
|
|
9
|
+
* a silent payment-link flow leaves the link to the reply ([11d3929](https://github.com/pedronastasi/manychat-ai-agent/commit/11d3929ae3c4e734d42fab62022dab33ac214e3b))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
* an offering's link does not count as one it is a prefix of ([426ade9](https://github.com/pedronastasi/manychat-ai-agent/commit/426ade9850e3e72354697c9961218558ad8b6d05))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Specs and Docs
|
|
18
|
+
|
|
19
|
+
* **specs:** a silent payment flow leaves the link to the reply ([4611b15](https://github.com/pedronastasi/manychat-ai-agent/commit/4611b15744f4a81bd3cf6943a2a1f39644d2bb7f))
|
|
20
|
+
|
|
3
21
|
## [0.24.1](https://github.com/pedronastasi/manychat-ai-agent/compare/v0.24.0...v0.24.1) (2026-10-16)
|
|
4
22
|
|
|
5
23
|
|
|
@@ -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);
|
|
@@ -186,6 +186,7 @@ export declare const ToolsSchema: z.ZodObject<{
|
|
|
186
186
|
}> & 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
187
|
offering: z.ZodOptional<z.ZodString>;
|
|
188
188
|
settleSeconds: z.ZodOptional<z.ZodNumber>;
|
|
189
|
+
silent: z.ZodOptional<z.ZodBoolean>;
|
|
189
190
|
}, z.core.$strip>>>;
|
|
190
191
|
tags: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
191
192
|
id: z.ZodString;
|
package/dist/contracts/config.js
CHANGED
|
@@ -315,8 +315,9 @@ export const ToolsSchema = z
|
|
|
315
315
|
repeatable: z.boolean().optional(),
|
|
316
316
|
/**
|
|
317
317
|
* `payment_link`: performing it writes the funnel field to `link_sent`
|
|
318
|
-
* (specs/023)
|
|
319
|
-
*
|
|
318
|
+
* (specs/023); with `silent`, the reply carries the link. `opening`:
|
|
319
|
+
* the server sends it on the contact's first model turn, and the
|
|
320
|
+
* model never does (specs/032).
|
|
320
321
|
*/
|
|
321
322
|
role: z.enum(['payment_link', 'opening']).optional(),
|
|
322
323
|
/**
|
|
@@ -338,9 +339,20 @@ export const ToolsSchema = z
|
|
|
338
339
|
* (specs/030). Absent, it does not wait.
|
|
339
340
|
*/
|
|
340
341
|
settleSeconds: z.number().int().min(0).max(MAX_SETTLE_SECONDS).optional(),
|
|
342
|
+
/**
|
|
343
|
+
* The flow sends the contact no message: it only records something
|
|
344
|
+
* in ManyChat, such as a tracking event. On the payment-link flow it
|
|
345
|
+
* means the reply carries the link (specs/023 § A silent payment
|
|
346
|
+
* flow leaves the link to the reply).
|
|
347
|
+
*/
|
|
348
|
+
silent: z.boolean().optional(),
|
|
341
349
|
}))
|
|
342
350
|
.default([])
|
|
343
351
|
.refine(uniqueIds, 'flow ids must be unique')
|
|
352
|
+
// Nothing plays, so there is nothing for the reply to wait for (specs/030).
|
|
353
|
+
.refine(flows => !flows.some(flow => flow.silent && flow.settleSeconds !== undefined), 'a "silent" flow may not have "settleSeconds"')
|
|
354
|
+
// The opening is the contact's first message from the tenant (specs/032).
|
|
355
|
+
.refine(flows => !flows.some(flow => flow.silent && flow.role === 'opening'), 'the "opening" flow may not be "silent"')
|
|
344
356
|
.refine(flows => flows.filter(flow => flow.role === 'payment_link').length <= 1, 'only one flow may have role "payment_link"')
|
|
345
357
|
// One payment flow serves every offering and branches on the offering
|
|
346
358
|
// field inside ManyChat (specs/028 § One payment flow serves every offering).
|