manychat-ai-agent 0.26.0 → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/dist/agent/tools/build.d.ts +23 -0
- package/dist/agent/tools/build.js +42 -0
- package/dist/agent/tools/contact-actions.d.ts +63 -0
- package/dist/agent/tools/contact-actions.js +64 -0
- package/dist/agent/tools/copy.d.ts +18 -0
- package/dist/agent/tools/copy.js +21 -0
- package/dist/agent/tools/get-contact.d.ts +5 -0
- package/dist/agent/tools/get-contact.js +26 -0
- package/dist/agent/tools/lookups.d.ts +90 -0
- package/dist/agent/tools/lookups.js +70 -0
- package/dist/agent/tools/notes.d.ts +4 -0
- package/dist/agent/tools/notes.js +36 -0
- package/dist/agent/tools/nudge.d.ts +4 -0
- package/dist/agent/tools/nudge.js +29 -0
- package/dist/agent/tools/send-flow.d.ts +4 -0
- package/dist/agent/tools/send-flow.js +115 -0
- package/dist/agent/tools/set-field.d.ts +7 -0
- package/dist/agent/tools/set-field.js +142 -0
- package/dist/agent/tools/stage.d.ts +92 -0
- package/dist/agent/tools/stage.js +176 -0
- package/dist/agent/tools/tags.d.ts +4 -0
- package/dist/agent/tools/tags.js +26 -0
- package/dist/agent/tools/turn.d.ts +93 -0
- package/dist/agent/tools/turn.js +93 -0
- package/dist/agent/tools.d.ts +11 -266
- package/dist/agent/tools.js +7 -738
- package/dist/channels/manychat/client.d.ts +11 -0
- package/dist/channels/manychat/client.js +16 -1
- package/dist/contracts/config/catalog.d.ts +55 -0
- package/dist/contracts/config/catalog.js +36 -0
- package/dist/contracts/config/channels.d.ts +14 -0
- package/dist/contracts/config/channels.js +50 -0
- package/dist/contracts/config/env.d.ts +51 -0
- package/dist/contracts/config/env.js +126 -0
- package/dist/contracts/config/funnel.d.ts +20 -0
- package/dist/contracts/config/funnel.js +27 -0
- package/dist/contracts/config/prompt-overrides.d.ts +26 -0
- package/dist/contracts/config/prompt-overrides.js +26 -0
- package/dist/contracts/config/rules.d.ts +54 -0
- package/dist/contracts/config/rules.js +98 -0
- package/dist/contracts/config/tools.d.ts +121 -0
- package/dist/contracts/config/tools.js +286 -0
- package/dist/contracts/config.d.ts +11 -329
- package/dist/contracts/config.js +11 -650
- package/dist/server.js +1 -1
- package/dist/turn/admission.d.ts +52 -0
- package/dist/turn/admission.js +94 -0
- package/dist/turn/completion.d.ts +23 -0
- package/dist/turn/completion.js +1 -0
- package/dist/turn/context.d.ts +23 -0
- package/dist/turn/context.js +33 -0
- package/dist/turn/deferred.d.ts +38 -0
- package/dist/turn/deferred.js +76 -0
- package/dist/turn/delivery.d.ts +100 -0
- package/dist/turn/delivery.js +162 -0
- package/dist/turn/fold.d.ts +44 -0
- package/dist/turn/fold.js +59 -0
- package/dist/{routes/turn.d.ts → turn/handler.d.ts} +14 -68
- package/dist/turn/handler.js +335 -0
- package/dist/turn/logger.d.ts +10 -0
- package/dist/turn/logger.js +11 -0
- package/dist/turn/media.d.ts +46 -0
- package/dist/turn/media.js +109 -0
- package/dist/turn/order.d.ts +49 -0
- package/dist/turn/order.js +115 -0
- package/dist/turn/prechecks.d.ts +30 -0
- package/dist/turn/prechecks.js +48 -0
- package/dist/turn/race.d.ts +7 -0
- package/dist/turn/race.js +18 -0
- package/dist/turn/recorder.d.ts +86 -0
- package/dist/turn/recorder.js +125 -0
- package/dist/turn/replies.d.ts +15 -0
- package/dist/turn/replies.js +25 -0
- package/dist/turn/result.d.ts +20 -0
- package/dist/turn/result.js +1 -0
- package/dist/turn/scope.d.ts +83 -0
- package/dist/turn/scope.js +71 -0
- package/package.json +1 -1
- package/dist/routes/turn.js +0 -1007
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { tool } from 'ai';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { LINK_SENT, PREPARED } from "../../contracts/config.js";
|
|
4
|
+
import { eventFor } from "./contact-actions.js";
|
|
5
|
+
import { catalogOf, CONTACT_ASKED, idsOf, SENT_NOW, SILENT, silentResult, STAGED } from "./copy.js";
|
|
6
|
+
import { stageIndex } from "./lookups.js";
|
|
7
|
+
import { fitsOffering, NOT_PROSPECT } from "./turn.js";
|
|
8
|
+
/** `send_flow`: the content flows not yet sent to the contact (specs/023, 028, 029, 032). */
|
|
9
|
+
export function addSendFlow(turn, tools) {
|
|
10
|
+
const { config, stage, contact, options, funnel } = turn;
|
|
11
|
+
if (turn.unsent.length > 0) {
|
|
12
|
+
const flows = new Map(turn.unsent.map(flow => [flow.id, flow]));
|
|
13
|
+
// The enum holds every unsent flow, so one the agent makes available by
|
|
14
|
+
// setting the offering this turn can still be named; the description lists
|
|
15
|
+
// only those it can accept now.
|
|
16
|
+
const available = turn.unsent.filter(flow => fitsOffering(flow, turn.turnOffering()));
|
|
17
|
+
// With an offering known, an empty list means its content was all sent:
|
|
18
|
+
// told to set the offering, the model could switch it just to have something to
|
|
19
|
+
// send, or try a write the lock refuses.
|
|
20
|
+
const listing = available.length > 0
|
|
21
|
+
? catalogOf(available)
|
|
22
|
+
: turn.turnOffering() === undefined
|
|
23
|
+
? 'None yet: record the contact’s offering with set_field first.'
|
|
24
|
+
: 'None: everything for this contact’s offering has been sent.';
|
|
25
|
+
const sends = options.flows;
|
|
26
|
+
// The payment link waits for readiness unless the contact asked to pay
|
|
27
|
+
// (specs/032 § The payment link waits for readiness).
|
|
28
|
+
const gated = funnel !== undefined && turn.unsent.some(flow => flow.role === 'payment_link');
|
|
29
|
+
tools.send_flow = tool({
|
|
30
|
+
description: `Send the contact one of these flows.\n${listing}\n${sends ? SENT_NOW : STAGED}` +
|
|
31
|
+
(turn.unsent.some(flow => flow.silent) ? `\n${SILENT}` : '') +
|
|
32
|
+
(gated ? `\n${CONTACT_ASKED}` : ''),
|
|
33
|
+
inputSchema: gated
|
|
34
|
+
? z.object({ flow: z.enum(idsOf(turn.unsent)), contactAsked: z.boolean().optional() })
|
|
35
|
+
: z.object({ flow: z.enum(idsOf(turn.unsent)) }),
|
|
36
|
+
execute: async (input) => {
|
|
37
|
+
if (turn.closed())
|
|
38
|
+
return sends ? { sent: false, reason: 'not_prospect' } : NOT_PROSPECT;
|
|
39
|
+
const { flow } = input;
|
|
40
|
+
const entry = flows.get(flow);
|
|
41
|
+
// Another offering's content is refused, whatever the model names.
|
|
42
|
+
if (!fitsOffering(entry, turn.turnOffering()))
|
|
43
|
+
return sends ? { sent: false } : { staged: false };
|
|
44
|
+
// Recorded only when it opened the gate, so the bypass rate counts
|
|
45
|
+
// bypasses, not every claim the model makes (specs/032).
|
|
46
|
+
const asked = entry.role === 'payment_link' &&
|
|
47
|
+
input.contactAsked === true &&
|
|
48
|
+
funnel !== undefined &&
|
|
49
|
+
turn.stageFloor() < stageIndex(PREPARED);
|
|
50
|
+
if (entry.role === 'payment_link' &&
|
|
51
|
+
funnel &&
|
|
52
|
+
!asked &&
|
|
53
|
+
turn.stageFloor() < stageIndex(PREPARED)) {
|
|
54
|
+
return sends
|
|
55
|
+
? { sent: false, reason: 'not_prepared' }
|
|
56
|
+
: { staged: false, reason: 'not_prepared' };
|
|
57
|
+
}
|
|
58
|
+
// The server, not the model, records that the link went out, and only
|
|
59
|
+
// once the flow itself has (specs/023 § The sale ends at the
|
|
60
|
+
// payment-link flow).
|
|
61
|
+
// Its event, if any, follows the write in turn (specs/027).
|
|
62
|
+
const event = funnel ? eventFor(config, contact, LINK_SENT) : undefined;
|
|
63
|
+
const followOn = entry.role === 'payment_link' && funnel
|
|
64
|
+
? {
|
|
65
|
+
tool: 'set_field',
|
|
66
|
+
id: funnel.id,
|
|
67
|
+
field: funnel.field,
|
|
68
|
+
value: LINK_SENT,
|
|
69
|
+
...(event ? { followOn: event } : {}),
|
|
70
|
+
}
|
|
71
|
+
: undefined;
|
|
72
|
+
const action = {
|
|
73
|
+
tool: 'send_flow',
|
|
74
|
+
id: flow,
|
|
75
|
+
flowNs: entry.flowNs,
|
|
76
|
+
...(followOn ? { followOn } : {}),
|
|
77
|
+
...(asked ? { contactAsked: true } : {}),
|
|
78
|
+
};
|
|
79
|
+
// What a silent flow leaves to the reply, said where the model reads
|
|
80
|
+
// the outcome (specs/023 § A silent payment flow leaves the link to
|
|
81
|
+
// the reply).
|
|
82
|
+
const silent = entry.silent ? { silent: silentResult(entry.role) } : {};
|
|
83
|
+
if (!sends) {
|
|
84
|
+
const staged = stage.stage(action);
|
|
85
|
+
return staged ? { staged, ...silent } : { staged };
|
|
86
|
+
}
|
|
87
|
+
// The opening goes first on the prospect turn (specs/034). A payment
|
|
88
|
+
// link is counted at link_sent before the await, so a funnel or offering
|
|
89
|
+
// write made beside it is refused rather than landing after it (specs/029).
|
|
90
|
+
const link = entry.role === 'payment_link' && funnel !== undefined;
|
|
91
|
+
if (link)
|
|
92
|
+
turn.linkStarting += 1;
|
|
93
|
+
try {
|
|
94
|
+
if (options.beforeFlow)
|
|
95
|
+
await options.beforeFlow();
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
// From here the reserved entry counts while the link is in flight.
|
|
99
|
+
if (link)
|
|
100
|
+
turn.linkStarting -= 1;
|
|
101
|
+
}
|
|
102
|
+
// Sent now, so the reply written after it follows it (specs/029). A
|
|
103
|
+
// payment link takes the turn's staged stage writes ahead of it, so
|
|
104
|
+
// its `link_sent` is the last stage ManyChat is given.
|
|
105
|
+
const first = entry.role === 'payment_link' && funnel
|
|
106
|
+
? stage.staged.filter(staged => staged.tool === 'set_field' && staged.id === funnel.id)
|
|
107
|
+
: [];
|
|
108
|
+
const sent = await stage.send(action, sends, first);
|
|
109
|
+
if (sent === undefined)
|
|
110
|
+
return { sent: false, reason: 'over the per-turn limit' };
|
|
111
|
+
return sent ? { sent, ...silent } : { sent };
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type ToolSet } from 'ai';
|
|
2
|
+
import type { ToolTurn } from './turn.ts';
|
|
3
|
+
/**
|
|
4
|
+
* `set_field`: the tenant's enum fields, the funnel, the offering and intent
|
|
5
|
+
* among them (specs/012, 023, 028, 032, 034, 045).
|
|
6
|
+
*/
|
|
7
|
+
export declare function addSetField(turn: ToolTurn, tools: ToolSet): void;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { tool } from 'ai';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { LINK_SENT, PROSPECT } from "../../contracts/config.js";
|
|
4
|
+
import { eventFor } from "./contact-actions.js";
|
|
5
|
+
import { idsOf, STAGED } from "./copy.js";
|
|
6
|
+
import { OFFERING_LOCKED_FROM, openingFlow, stageIndex } from "./lookups.js";
|
|
7
|
+
import { fitsOffering, NOT_PROSPECT } from "./turn.js";
|
|
8
|
+
/**
|
|
9
|
+
* `set_field`: the tenant's enum fields, the funnel, the offering and intent
|
|
10
|
+
* among them (specs/012, 023, 028, 032, 034, 045).
|
|
11
|
+
*/
|
|
12
|
+
export function addSetField(turn, tools) {
|
|
13
|
+
const { config, stage, contact, options, funnel, intent, offering } = turn;
|
|
14
|
+
if (config.fields.length > 0) {
|
|
15
|
+
// `link_sent` is the server's to write, so the model cannot name it.
|
|
16
|
+
const offered = config.fields.map(field => field.funnel
|
|
17
|
+
? { ...field, values: field.values.filter(value => value !== LINK_SENT) }
|
|
18
|
+
: field);
|
|
19
|
+
const fields = new Map(offered.map(field => [field.id, field]));
|
|
20
|
+
const values = [...new Set(offered.flatMap(field => field.values))];
|
|
21
|
+
const listing = offered
|
|
22
|
+
.map(field => `- ${field.id} (one of: ${field.values.join(', ')}): ${field.description}`)
|
|
23
|
+
.join('\n');
|
|
24
|
+
/**
|
|
25
|
+
* The flow tied to a stage the model just moved the contact to, sent as a
|
|
26
|
+
* flow the model calls is (specs/032 § A stage move can carry a flow):
|
|
27
|
+
* once per contact, only for the turn's offering, and only for the stage
|
|
28
|
+
* written, never one skipped.
|
|
29
|
+
*/
|
|
30
|
+
const overCap = (id) => ({ flowDropped: id, reason: 'over the per-turn limit' });
|
|
31
|
+
const sendTied = async (value) => {
|
|
32
|
+
const tied = config.flows.find(flow => flow.onStage === value);
|
|
33
|
+
if (!tied || !fitsOffering(tied, turn.turnOffering()))
|
|
34
|
+
return {};
|
|
35
|
+
if (!tied.repeatable && contact.sentFlows.has(tied.id))
|
|
36
|
+
return {};
|
|
37
|
+
if (stage.sent.some(sent => sent.action.tool === 'send_flow' && sent.action.id === tied.id)) {
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
const action = {
|
|
41
|
+
tool: 'send_flow',
|
|
42
|
+
id: tied.id,
|
|
43
|
+
flowNs: tied.flowNs,
|
|
44
|
+
origin: 'stage',
|
|
45
|
+
};
|
|
46
|
+
if (!options.flows) {
|
|
47
|
+
// `stage` refuses only over the cap; a repeat is reported as staged.
|
|
48
|
+
return stage.stage(action) ? { flowStaged: tied.id } : overCap(tied.id);
|
|
49
|
+
}
|
|
50
|
+
if (options.beforeFlow)
|
|
51
|
+
await options.beforeFlow();
|
|
52
|
+
const sent = await stage.send(action, options.flows);
|
|
53
|
+
// Dropped over the cap is not refused by ManyChat: the model may still
|
|
54
|
+
// send the flow itself on a later turn (specs/032).
|
|
55
|
+
if (sent === undefined)
|
|
56
|
+
return overCap(tied.id);
|
|
57
|
+
return sent ? { flowSent: tied.id } : { flowRefused: tied.id };
|
|
58
|
+
};
|
|
59
|
+
tools.set_field = tool({
|
|
60
|
+
description: `Record one of these choices on the contact.\n${listing}\n${STAGED}`,
|
|
61
|
+
// Never free text (specs/012 § Free-text field values are refused). The
|
|
62
|
+
// enum covers every field's values; the refinement holds each value to
|
|
63
|
+
// its own field.
|
|
64
|
+
inputSchema: z
|
|
65
|
+
.object({ field: z.enum(idsOf(config.fields)), value: z.enum(values) })
|
|
66
|
+
.refine(input => fields.get(input.field).values.includes(input.value), {
|
|
67
|
+
message: 'value is not one of this field’s configured values',
|
|
68
|
+
path: ['value'],
|
|
69
|
+
}),
|
|
70
|
+
execute: async ({ field, value }) => {
|
|
71
|
+
if (field === intent?.id) {
|
|
72
|
+
// Intent moves one way, and `prospect` is final (specs/034 § Intent
|
|
73
|
+
// is a field the model records).
|
|
74
|
+
const before = turn.turnIntent();
|
|
75
|
+
if (before === PROSPECT && value !== PROSPECT)
|
|
76
|
+
return { staged: false };
|
|
77
|
+
const staged = stage.stage({
|
|
78
|
+
tool: 'set_field',
|
|
79
|
+
id: field,
|
|
80
|
+
field: fields.get(field).field,
|
|
81
|
+
value,
|
|
82
|
+
});
|
|
83
|
+
// The opening belongs to the turn that first stages `prospect`, and
|
|
84
|
+
// the result says so, since the server could not know before the
|
|
85
|
+
// loop ran (specs/034 § The opening waits for a prospect).
|
|
86
|
+
const opening = openingFlow(config);
|
|
87
|
+
return staged &&
|
|
88
|
+
value === PROSPECT &&
|
|
89
|
+
before !== PROSPECT &&
|
|
90
|
+
contact.openingDue &&
|
|
91
|
+
opening
|
|
92
|
+
? { staged, openingQueued: { flow: opening.id, description: opening.description } }
|
|
93
|
+
: { staged };
|
|
94
|
+
}
|
|
95
|
+
if (turn.closed())
|
|
96
|
+
return NOT_PROSPECT;
|
|
97
|
+
// The stage only moves forward (specs/023 § The funnel is a field the
|
|
98
|
+
// agent moves).
|
|
99
|
+
const floor = turn.stageFloor();
|
|
100
|
+
if (field === funnel?.id && stageIndex(value) < floor)
|
|
101
|
+
return { staged: false };
|
|
102
|
+
if (field !== offering?.id) {
|
|
103
|
+
// The server's measurement of the write, never the model's choice
|
|
104
|
+
// (specs/027 § An event is a measurement).
|
|
105
|
+
const event = field === funnel?.id ? eventFor(config, contact, value) : undefined;
|
|
106
|
+
const staged = stage.stage({
|
|
107
|
+
tool: 'set_field',
|
|
108
|
+
id: field,
|
|
109
|
+
field: fields.get(field).field,
|
|
110
|
+
value,
|
|
111
|
+
...(event ? { followOn: event } : {}),
|
|
112
|
+
});
|
|
113
|
+
const moved = field === funnel?.id && staged && stageIndex(value) > floor;
|
|
114
|
+
return moved ? { staged, ...(await sendTied(value)) } : { staged };
|
|
115
|
+
}
|
|
116
|
+
// Once an offering and its price have been put to the contact, a person
|
|
117
|
+
// decides a switch (specs/028 § The offering may change until the offer),
|
|
118
|
+
// unless the tenant keeps it open (specs/045).
|
|
119
|
+
if (!offering?.switchable && turn.stageFloor() >= stageIndex(OFFERING_LOCKED_FROM)) {
|
|
120
|
+
return { staged: false };
|
|
121
|
+
}
|
|
122
|
+
const before = turn.turnOffering();
|
|
123
|
+
const staged = stage.stage({
|
|
124
|
+
tool: 'set_field',
|
|
125
|
+
id: field,
|
|
126
|
+
field: fields.get(field).field,
|
|
127
|
+
value,
|
|
128
|
+
offering: true,
|
|
129
|
+
});
|
|
130
|
+
if (!staged)
|
|
131
|
+
return { staged };
|
|
132
|
+
// Available in the same turn, and said so, since the send_flow
|
|
133
|
+
// description was written before the offering was known.
|
|
134
|
+
const opened = turn.unsent.filter(flow => flow.offering === value && !fitsOffering(flow, before));
|
|
135
|
+
return {
|
|
136
|
+
staged,
|
|
137
|
+
flowsAvailable: opened.map(flow => ({ flow: flow.id, description: flow.description })),
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { ActionRecord, ActionStatus, PerformableAction, StagedAction } from '../../contracts/agent.ts';
|
|
2
|
+
import type { FlowSends, SendFlowAction } from '../flows.ts';
|
|
3
|
+
/**
|
|
4
|
+
* Bounds the burst one turn can put through the ManyChat rate limiter, one
|
|
5
|
+
* request per action. Eight fit a flow, a funnel stage, two qualification
|
|
6
|
+
* fields, a tag and three notes (specs/024). Chosen, not measured.
|
|
7
|
+
*/
|
|
8
|
+
export declare const MAX_ACTIONS_PER_TURN = 8;
|
|
9
|
+
/** `send_flow intro_course_brochure`, `set_field preferred_shift=evening`. */
|
|
10
|
+
export declare function describeAction(action: {
|
|
11
|
+
tool: string;
|
|
12
|
+
id: string;
|
|
13
|
+
value?: string | undefined;
|
|
14
|
+
}): string;
|
|
15
|
+
/**
|
|
16
|
+
* The entry recorded on the turn: ids and values, never ManyChat names. A
|
|
17
|
+
* note is recorded by its length, never its text (specs/024 § Note text never
|
|
18
|
+
* reaches the record or the logs).
|
|
19
|
+
*/
|
|
20
|
+
export declare function recordOf(action: PerformableAction, status: ActionStatus, error?: string): ActionRecord;
|
|
21
|
+
/**
|
|
22
|
+
* Everything the model staged on one turn. A tool's `execute` writes here and
|
|
23
|
+
* nothing else: no ManyChat write is made inside the model call, because at
|
|
24
|
+
* that point the model has not decided whether to escalate and the guardrails
|
|
25
|
+
* have not run (ADR-0010). The exception is a flow on an inbound turn, sent
|
|
26
|
+
* when called so the reply can follow it (specs/029, ADR-0019); it is kept
|
|
27
|
+
* here with what became of it, in call order.
|
|
28
|
+
*
|
|
29
|
+
* Created by the caller rather than the runner, so what was staged is still
|
|
30
|
+
* known when the model call is aborted and never returns.
|
|
31
|
+
*/
|
|
32
|
+
export declare class ActionStage {
|
|
33
|
+
private readonly accepted;
|
|
34
|
+
private readonly overCap;
|
|
35
|
+
private readonly sentNow;
|
|
36
|
+
/** Staged and sent entries in the order the model made them, for the record. */
|
|
37
|
+
private readonly order;
|
|
38
|
+
/** Sends the cap does not count: the opening flow is the server's (specs/032). */
|
|
39
|
+
private uncapped;
|
|
40
|
+
/**
|
|
41
|
+
* Whether the action is staged. A repeat of one already staged is. Compared
|
|
42
|
+
* whole rather than by record, since two notes of one length differ.
|
|
43
|
+
*/
|
|
44
|
+
stage(action: StagedAction): boolean;
|
|
45
|
+
/** A flow sent this turn counts against the cap as a staged action does. */
|
|
46
|
+
private get full();
|
|
47
|
+
/**
|
|
48
|
+
* Sends a flow now (specs/029). Returns whether ManyChat accepted it, or
|
|
49
|
+
* `undefined` when it is over the cap and was not sent. A flow already sent
|
|
50
|
+
* this turn is not sent again; its first outcome stands.
|
|
51
|
+
*
|
|
52
|
+
* `first` are actions already staged that must reach ManyChat before the
|
|
53
|
+
* flow: a payment link's `link_sent` would otherwise be overwritten by an
|
|
54
|
+
* earlier funnel write performed after the reply (specs/029 § A payment link
|
|
55
|
+
* takes the turn's stage writes with it). They are sent ahead of it, keep
|
|
56
|
+
* their place in the record, and are no longer staged.
|
|
57
|
+
*/
|
|
58
|
+
send(action: SendFlowAction, sends: FlowSends, first?: readonly StagedAction[],
|
|
59
|
+
/** Not counted against the cap: the server's opening flow (specs/032). */
|
|
60
|
+
options?: {
|
|
61
|
+
uncapped?: boolean;
|
|
62
|
+
}): Promise<boolean | undefined>;
|
|
63
|
+
/**
|
|
64
|
+
* Resolves once every send started this turn has its answer. A call aborted
|
|
65
|
+
* while a request is in flight does not stop the request, so the record waits
|
|
66
|
+
* for it rather than keeping the placeholder (specs/029). Bounded by the
|
|
67
|
+
* client's request timeout.
|
|
68
|
+
*/
|
|
69
|
+
settled(): Promise<void>;
|
|
70
|
+
/** Actions sent this turn, whatever became of them. */
|
|
71
|
+
get sent(): readonly {
|
|
72
|
+
action: StagedAction;
|
|
73
|
+
records: readonly ActionRecord[];
|
|
74
|
+
pending: boolean;
|
|
75
|
+
}[];
|
|
76
|
+
/** In the order the model staged them, which is the order they are performed in. */
|
|
77
|
+
get staged(): readonly StagedAction[];
|
|
78
|
+
get dropped(): readonly StagedAction[];
|
|
79
|
+
/**
|
|
80
|
+
* What an escalated turn still performs: the notes marked `onEscalation`,
|
|
81
|
+
* and only when the model or the confidence threshold escalated (specs/024
|
|
82
|
+
* § A handoff summary survives the escalation it describes).
|
|
83
|
+
*/
|
|
84
|
+
survivors(keepNotes: boolean): StagedAction[];
|
|
85
|
+
/**
|
|
86
|
+
* The turn's record before anything is performed: every accepted action
|
|
87
|
+
* with `status`, then those dropped over the cap. On an escalated turn
|
|
88
|
+
* (`discarded`), an action in `kept` is recorded `staged` instead, since it
|
|
89
|
+
* will still be performed.
|
|
90
|
+
*/
|
|
91
|
+
records(status: 'staged' | 'discarded', kept?: readonly StagedAction[]): ActionRecord[];
|
|
92
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounds the burst one turn can put through the ManyChat rate limiter, one
|
|
3
|
+
* request per action. Eight fit a flow, a funnel stage, two qualification
|
|
4
|
+
* fields, a tag and three notes (specs/024). Chosen, not measured.
|
|
5
|
+
*/
|
|
6
|
+
export const MAX_ACTIONS_PER_TURN = 8;
|
|
7
|
+
/** `send_flow intro_course_brochure`, `set_field preferred_shift=evening`. */
|
|
8
|
+
export function describeAction(action) {
|
|
9
|
+
return action.value === undefined
|
|
10
|
+
? `${action.tool} ${action.id}`
|
|
11
|
+
: `${action.tool} ${action.id}=${action.value}`;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The entry recorded on the turn: ids and values, never ManyChat names. A
|
|
15
|
+
* note is recorded by its length, never its text (specs/024 § Note text never
|
|
16
|
+
* reaches the record or the logs).
|
|
17
|
+
*/
|
|
18
|
+
export function recordOf(action, status, error) {
|
|
19
|
+
return {
|
|
20
|
+
tool: action.tool,
|
|
21
|
+
id: action.id,
|
|
22
|
+
...(action.tool === 'set_field' ? { value: action.value } : {}),
|
|
23
|
+
...(action.tool === 'write_note' ? { length: action.text.length } : {}),
|
|
24
|
+
status,
|
|
25
|
+
...(error === undefined ? {} : { error }),
|
|
26
|
+
...(action.tool === 'send_flow' && action.origin ? { origin: action.origin } : {}),
|
|
27
|
+
...(action.tool === 'send_flow' && action.contactAsked ? { contactAsked: true } : {}),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Everything the model staged on one turn. A tool's `execute` writes here and
|
|
32
|
+
* nothing else: no ManyChat write is made inside the model call, because at
|
|
33
|
+
* that point the model has not decided whether to escalate and the guardrails
|
|
34
|
+
* have not run (ADR-0010). The exception is a flow on an inbound turn, sent
|
|
35
|
+
* when called so the reply can follow it (specs/029, ADR-0019); it is kept
|
|
36
|
+
* here with what became of it, in call order.
|
|
37
|
+
*
|
|
38
|
+
* Created by the caller rather than the runner, so what was staged is still
|
|
39
|
+
* known when the model call is aborted and never returns.
|
|
40
|
+
*/
|
|
41
|
+
export class ActionStage {
|
|
42
|
+
accepted = [];
|
|
43
|
+
overCap = [];
|
|
44
|
+
sentNow = [];
|
|
45
|
+
/** Staged and sent entries in the order the model made them, for the record. */
|
|
46
|
+
order = [];
|
|
47
|
+
/** Sends the cap does not count: the opening flow is the server's (specs/032). */
|
|
48
|
+
uncapped = 0;
|
|
49
|
+
/**
|
|
50
|
+
* Whether the action is staged. A repeat of one already staged is. Compared
|
|
51
|
+
* whole rather than by record, since two notes of one length differ.
|
|
52
|
+
*/
|
|
53
|
+
stage(action) {
|
|
54
|
+
const key = JSON.stringify(action);
|
|
55
|
+
const same = (existing) => JSON.stringify(existing) === key;
|
|
56
|
+
if (this.accepted.some(same))
|
|
57
|
+
return true;
|
|
58
|
+
if (this.full) {
|
|
59
|
+
if (!this.overCap.some(same))
|
|
60
|
+
this.overCap.push(action);
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
this.accepted.push(action);
|
|
64
|
+
this.order.push(action);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
/** A flow sent this turn counts against the cap as a staged action does. */
|
|
68
|
+
get full() {
|
|
69
|
+
return this.accepted.length + this.sentNow.length - this.uncapped >= MAX_ACTIONS_PER_TURN;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Sends a flow now (specs/029). Returns whether ManyChat accepted it, or
|
|
73
|
+
* `undefined` when it is over the cap and was not sent. A flow already sent
|
|
74
|
+
* this turn is not sent again; its first outcome stands.
|
|
75
|
+
*
|
|
76
|
+
* `first` are actions already staged that must reach ManyChat before the
|
|
77
|
+
* flow: a payment link's `link_sent` would otherwise be overwritten by an
|
|
78
|
+
* earlier funnel write performed after the reply (specs/029 § A payment link
|
|
79
|
+
* takes the turn's stage writes with it). They are sent ahead of it, keep
|
|
80
|
+
* their place in the record, and are no longer staged.
|
|
81
|
+
*/
|
|
82
|
+
async send(action, sends, first = [],
|
|
83
|
+
/** Not counted against the cap: the server's opening flow (specs/032). */
|
|
84
|
+
options = {}) {
|
|
85
|
+
const earlier = this.sentNow.find(entry => entry.action.tool === 'send_flow' && entry.action.id === action.id);
|
|
86
|
+
if (earlier) {
|
|
87
|
+
// A repeat made while the first request is in flight (parallel calls in
|
|
88
|
+
// one step) gets its real outcome, not the placeholder.
|
|
89
|
+
await earlier.done;
|
|
90
|
+
return earlier.records[0]?.status === 'performed';
|
|
91
|
+
}
|
|
92
|
+
if (!options.uncapped && this.full) {
|
|
93
|
+
if (!this.overCap.some(entry => entry.tool === 'send_flow' && entry.id === action.id)) {
|
|
94
|
+
this.overCap.push(action);
|
|
95
|
+
}
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
// Reserved before the request, so a second call made while it is in
|
|
99
|
+
// flight neither sends the flow twice nor slips past the cap. Recorded as
|
|
100
|
+
// failed until ManyChat answers: a turn that ends while the request is in
|
|
101
|
+
// flight cannot say it went out.
|
|
102
|
+
const reserve = (entryAction) => ({
|
|
103
|
+
action: entryAction,
|
|
104
|
+
records: [recordOf(entryAction, 'failed', 'no answer before the turn ended')],
|
|
105
|
+
done: Promise.resolve(),
|
|
106
|
+
pending: true,
|
|
107
|
+
});
|
|
108
|
+
const ahead = first.filter(staged => this.accepted.includes(staged)).map(reserve);
|
|
109
|
+
for (const entry of ahead) {
|
|
110
|
+
const at = this.accepted.indexOf(entry.action);
|
|
111
|
+
this.accepted.splice(at, 1);
|
|
112
|
+
this.sentNow.push(entry);
|
|
113
|
+
this.order[this.order.indexOf(entry.action)] = { sent: this.sentNow.length - 1 };
|
|
114
|
+
}
|
|
115
|
+
const entry = reserve(action);
|
|
116
|
+
if (options.uncapped)
|
|
117
|
+
this.uncapped += 1;
|
|
118
|
+
this.sentNow.push(entry);
|
|
119
|
+
this.order.push({ sent: this.sentNow.length - 1 });
|
|
120
|
+
const done = sends.send([...ahead.map(staged => staged.action), action]).then(groups => {
|
|
121
|
+
[...ahead, entry].forEach((sent, index) => {
|
|
122
|
+
sent.records = groups[index] ?? sent.records;
|
|
123
|
+
sent.pending = false;
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
for (const sent of [...ahead, entry])
|
|
127
|
+
sent.done = done;
|
|
128
|
+
await done;
|
|
129
|
+
return entry.records[0]?.status === 'performed';
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Resolves once every send started this turn has its answer. A call aborted
|
|
133
|
+
* while a request is in flight does not stop the request, so the record waits
|
|
134
|
+
* for it rather than keeping the placeholder (specs/029). Bounded by the
|
|
135
|
+
* client's request timeout.
|
|
136
|
+
*/
|
|
137
|
+
async settled() {
|
|
138
|
+
await Promise.all(this.sentNow.map(entry => entry.done));
|
|
139
|
+
}
|
|
140
|
+
/** Actions sent this turn, whatever became of them. */
|
|
141
|
+
get sent() {
|
|
142
|
+
return this.sentNow;
|
|
143
|
+
}
|
|
144
|
+
/** In the order the model staged them, which is the order they are performed in. */
|
|
145
|
+
get staged() {
|
|
146
|
+
return this.accepted;
|
|
147
|
+
}
|
|
148
|
+
get dropped() {
|
|
149
|
+
return this.overCap;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* What an escalated turn still performs: the notes marked `onEscalation`,
|
|
153
|
+
* and only when the model or the confidence threshold escalated (specs/024
|
|
154
|
+
* § A handoff summary survives the escalation it describes).
|
|
155
|
+
*/
|
|
156
|
+
survivors(keepNotes) {
|
|
157
|
+
return keepNotes
|
|
158
|
+
? this.accepted.filter(action => action.tool === 'write_note' && action.onEscalation)
|
|
159
|
+
: [];
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The turn's record before anything is performed: every accepted action
|
|
163
|
+
* with `status`, then those dropped over the cap. On an escalated turn
|
|
164
|
+
* (`discarded`), an action in `kept` is recorded `staged` instead, since it
|
|
165
|
+
* will still be performed.
|
|
166
|
+
*/
|
|
167
|
+
records(status, kept = []) {
|
|
168
|
+
// A flow already sent keeps its outcome: an escalation cannot recall it.
|
|
169
|
+
return [
|
|
170
|
+
...this.order.flatMap(entry => 'sent' in entry
|
|
171
|
+
? this.sentNow[entry.sent].records
|
|
172
|
+
: [recordOf(entry, kept.includes(entry) ? 'staged' : status)]),
|
|
173
|
+
...this.overCap.map(action => recordOf(action, 'dropped_over_cap')),
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { tool } from 'ai';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { catalogOf, idsOf, STAGED } from "./copy.js";
|
|
4
|
+
import { NOT_PROSPECT } from "./turn.js";
|
|
5
|
+
/** `add_tag` and `remove_tag`: the tenant's tags (specs/012). */
|
|
6
|
+
export function addTagTools(turn, tools) {
|
|
7
|
+
const { config, stage } = turn;
|
|
8
|
+
if (config.tags.length > 0) {
|
|
9
|
+
const tags = new Map(config.tags.map(entry => [entry.id, entry]));
|
|
10
|
+
const input = z.object({ tag: z.enum(idsOf(config.tags)) });
|
|
11
|
+
tools.add_tag = tool({
|
|
12
|
+
description: `Add one of these tags to the contact.\n${catalogOf(config.tags)}\n${STAGED}`,
|
|
13
|
+
inputSchema: input,
|
|
14
|
+
execute: ({ tag }) => turn.closed()
|
|
15
|
+
? NOT_PROSPECT
|
|
16
|
+
: { staged: stage.stage({ tool: 'add_tag', id: tag, tag: tags.get(tag).tag }) },
|
|
17
|
+
});
|
|
18
|
+
tools.remove_tag = tool({
|
|
19
|
+
description: `Remove one of these tags from the contact.\n${catalogOf(config.tags)}\n${STAGED}`,
|
|
20
|
+
inputSchema: input,
|
|
21
|
+
execute: ({ tag }) => turn.closed()
|
|
22
|
+
? NOT_PROSPECT
|
|
23
|
+
: { staged: stage.stage({ tool: 'remove_tag', id: tag, tag: tags.get(tag).tag }) },
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Tools } from '../../contracts/config.ts';
|
|
2
|
+
import type { Plugins } from '../../plugins/plugins.ts';
|
|
3
|
+
import type { PluginReads } from '../../plugins/reads.ts';
|
|
4
|
+
import type { FlowSends } from '../flows.ts';
|
|
5
|
+
import type { ContactActions } from './contact-actions.ts';
|
|
6
|
+
import { funnelField, intentField, offeringField } from './lookups.ts';
|
|
7
|
+
import type { Intent } from './lookups.ts';
|
|
8
|
+
import type { ActionStage } from './stage.ts';
|
|
9
|
+
export interface BuildToolsOptions {
|
|
10
|
+
/**
|
|
11
|
+
* A nudge turn is offered no `schedule_nudge`, so a nudge never schedules
|
|
12
|
+
* another (specs/025).
|
|
13
|
+
*/
|
|
14
|
+
nudgeTurn?: boolean;
|
|
15
|
+
/** Sends a flow when it is called instead of staging it (specs/029). */
|
|
16
|
+
flows?: FlowSends | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* Runs before the turn's first flow goes out: on the prospect turn it
|
|
19
|
+
* sends the opening, so no content reaches the contact ahead of it
|
|
20
|
+
* (specs/032 § The opening flow is the server's, not the model's).
|
|
21
|
+
*/
|
|
22
|
+
beforeFlow?: (() => Promise<void>) | undefined;
|
|
23
|
+
/** The tenant's plugin tools, staged as the built-in ones are (specs/036). */
|
|
24
|
+
plugins?: Plugins | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* The turn's plugin reads. Absent, no read tool is offered: as for
|
|
27
|
+
* `get_contact`, only a turn that reads history may read (specs/039).
|
|
28
|
+
*/
|
|
29
|
+
pluginReads?: PluginReads | undefined;
|
|
30
|
+
}
|
|
31
|
+
/** What a sales tool returns while the contact is not a prospect (specs/034). */
|
|
32
|
+
export declare const NOT_PROSPECT: {
|
|
33
|
+
readonly staged: false;
|
|
34
|
+
readonly reason: "not_prospect";
|
|
35
|
+
};
|
|
36
|
+
/** Whether a flow serves the turn's offering: its own, or every one (specs/028). */
|
|
37
|
+
export declare const fitsOffering: (flow: {
|
|
38
|
+
offering?: string | undefined;
|
|
39
|
+
}, current: string | undefined) => boolean;
|
|
40
|
+
/**
|
|
41
|
+
* What every tool of one turn reads as it runs: the gate on intent, the
|
|
42
|
+
* turn's offering and the floor under the funnel stage. Read from the stage
|
|
43
|
+
* each time, so a write staged by one tool is seen by the next, in the same
|
|
44
|
+
* step included.
|
|
45
|
+
*/
|
|
46
|
+
export declare class ToolTurn {
|
|
47
|
+
readonly config: Tools;
|
|
48
|
+
readonly stage: ActionStage;
|
|
49
|
+
readonly contact: ContactActions;
|
|
50
|
+
readonly options: BuildToolsOptions;
|
|
51
|
+
readonly funnel: ReturnType<typeof funnelField>;
|
|
52
|
+
readonly intent: ReturnType<typeof intentField>;
|
|
53
|
+
readonly offering: ReturnType<typeof offeringField>;
|
|
54
|
+
/**
|
|
55
|
+
* A flow already sent to this contact is not offered again, so a repeat is
|
|
56
|
+
* unrepresentable rather than discouraged (specs/023 § Every content flow
|
|
57
|
+
* is a leaf, sent once). An offering change does not bring one back
|
|
58
|
+
* (specs/028). The opening flow is the server's to send, never the model's
|
|
59
|
+
* (specs/032).
|
|
60
|
+
*/
|
|
61
|
+
readonly unsent: Tools['flows'];
|
|
62
|
+
/**
|
|
63
|
+
* A payment link marked here before any await on its way out, so a call
|
|
64
|
+
* beside it in the same step already sees it (specs/029, specs/032).
|
|
65
|
+
*/
|
|
66
|
+
linkStarting: number;
|
|
67
|
+
constructor(config: Tools, stage: ActionStage, contact: ContactActions, options: BuildToolsOptions);
|
|
68
|
+
/**
|
|
69
|
+
* The contact's intent as the server knows it, a write staged earlier this
|
|
70
|
+
* turn included, so a first message can be recorded as a prospect and sold
|
|
71
|
+
* to in one turn (specs/034 § Until intent is prospect, the sale's tools
|
|
72
|
+
* refuse).
|
|
73
|
+
*/
|
|
74
|
+
turnIntent(): Intent | undefined;
|
|
75
|
+
/**
|
|
76
|
+
* Refused rather than removed from the enums: removing them would rule out
|
|
77
|
+
* the same-turn unlock. A refusal makes no request and takes no slot of the
|
|
78
|
+
* cap (specs/034).
|
|
79
|
+
*/
|
|
80
|
+
closed(): boolean;
|
|
81
|
+
/**
|
|
82
|
+
* The turn's offering: one staged on the offering field earlier in this
|
|
83
|
+
* turn, or else the contact's (specs/028 § A flow belongs to one offering or
|
|
84
|
+
* to all).
|
|
85
|
+
*/
|
|
86
|
+
turnOffering(): string | undefined;
|
|
87
|
+
/**
|
|
88
|
+
* The earliest stage a write may name: the last one performed for this
|
|
89
|
+
* contact, or a later one already staged this turn. Parallel calls in one
|
|
90
|
+
* step must not walk a lead back any more than a later turn may.
|
|
91
|
+
*/
|
|
92
|
+
stageFloor(): number;
|
|
93
|
+
}
|