manychat-ai-agent 0.25.0 → 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 CHANGED
@@ -1,5 +1,32 @@
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
+
3
30
  ## [0.25.0](https://github.com/pedronastasi/manychat-ai-agent/compare/v0.24.1...v0.25.0) (2026-10-16)
4
31
 
5
32
 
@@ -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;
@@ -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.
@@ -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 index = current.indexOf(place);
54
- return index >= 0 && current.slice(index + 1).some(later => later.claimed);
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) {
@@ -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
- if (slot.waited) {
154
- let timer;
155
- const late = new Promise(resolve => {
156
- timer = setTimeout(() => resolve('late'), Math.max(0, deadlineAt - Date.now()));
157
- });
158
- const waited = await Promise.race([slot.ready, late]);
159
- clearTimeout(timer);
160
- if (waited === 'late') {
161
- void slot.ready
162
- .then(async (ready) => {
163
- if (ready === 'expired')
164
- expired();
165
- if (readsHistory && slot.followed() && this.foldable(inbound)) {
166
- await this.fold(inbound, binding);
167
- }
168
- else
169
- await this.run(inbound, { ...opts, late: true });
170
- })
171
- .catch(async (error) => {
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
- // The contact wrote again while this message waited: the later turn
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.25.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.11.2",
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.28",
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",