@flow-engineer/flow 0.2.2 → 0.2.3

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.
@@ -28,8 +28,8 @@ export declare class Client {
28
28
  get<T>(path: string): Promise<T>;
29
29
  /** A file's bytes: GET /v1/files/{id} answers them or redirects to a signed URL, which fetch follows without the key. */
30
30
  download(fileId: string): Promise<Uint8Array>;
31
- /** A POST with its own Idempotency-Key, kept across the retries of this one call; `tries: 1` for courtesies. */
32
- post<T>(path: string, body: unknown, tries?: number): Promise<T>;
31
+ /** A POST with its own Idempotency-Key (the caller's, else a new one), kept across the retries of this one call; `tries: 1` for courtesies. */
32
+ post<T>(path: string, body: unknown, tries?: number, idempotencyKey?: string): Promise<T>;
33
33
  private request;
34
34
  }
35
35
  /**
@@ -66,9 +66,9 @@ export class Client {
66
66
  throw await errorOf(response);
67
67
  return new Uint8Array(await response.arrayBuffer());
68
68
  }
69
- /** A POST with its own Idempotency-Key, kept across the retries of this one call; `tries: 1` for courtesies. */
70
- post(path, body, tries = TRIES) {
71
- return this.request("POST", path, body, randomUUID(), tries);
69
+ /** A POST with its own Idempotency-Key (the caller's, else a new one), kept across the retries of this one call; `tries: 1` for courtesies. */
70
+ post(path, body, tries = TRIES, idempotencyKey = randomUUID()) {
71
+ return this.request("POST", path, body, idempotencyKey, tries);
72
72
  }
73
73
  async request(method, path, body, idempotencyKey, tries = TRIES) {
74
74
  if (!this.apiKey)
@@ -97,16 +97,16 @@ export function flowMessaging(options = {}) {
97
97
  return { phone: to.startsWith("+") ? to : `+${to}` };
98
98
  }
99
99
  /** One message: into the person's conversation, or a new one from the channel's sender. */
100
- async function send(route, to, content, replyTo, about = replyTo) {
100
+ async function send(route, to, content, replyTo, about = replyTo, idempotencyKey) {
101
101
  try {
102
102
  const conversation = await conversationFor(route.name, to, about);
103
103
  let sent;
104
104
  if (conversation)
105
- sent = await client.post(`/v1/conversations/${idSegment("conv", conversation)}/messages`, { content, ...(replyTo ? { reply_to: replyTo } : {}) });
105
+ sent = await client.post(`/v1/conversations/${idSegment("conv", conversation)}/messages`, { content, ...(replyTo ? { reply_to: replyTo } : {}) }, undefined, idempotencyKey);
106
106
  else {
107
107
  if (!route.sender)
108
108
  throw new Error(`no conversation with them on ${route.name} yet, and no sender to start one (set FLOW_MESSAGING_${route.kind.toUpperCase()}_SENDER)`);
109
- sent = await client.post("/v1/messages", { sender: route.sender, to: recipient(route.kind, to), content });
109
+ sent = await client.post("/v1/messages", { sender: route.sender, to: recipient(route.kind, to), content }, undefined, idempotencyKey);
110
110
  }
111
111
  flowConversation.set(key(route.name, to), sent.conversation);
112
112
  }
@@ -176,19 +176,20 @@ export function flowMessaging(options = {}) {
176
176
  return {
177
177
  sendMessage: async (to, message) => {
178
178
  const [first, ...rest] = contents(route.kind, message);
179
- await send(route, to, first, message.replyTo);
180
- for (const next of rest)
181
- await send(route, to, next);
179
+ const key = message.idempotencyKey;
180
+ await send(route, to, first, message.replyTo, message.replyTo, key);
181
+ for (const [i, next] of rest.entries())
182
+ await send(route, to, next, undefined, undefined, key && `${key}:${i + 1}`);
182
183
  },
183
184
  react: (to, messageId, emoji) => send(route, to, { type: "reaction", message_id: messageId, emoji }, undefined, messageId),
184
185
  typing: (to, on) => courtesy(route, to, "typing", { state: on ? "on" : "off" }),
185
186
  markRead: (to, messageIds) => courtesy(route, to, "read", { up_to: messageIds.at(-1) }, messageIds.at(-1)),
186
- sendTemplate: async (to, wanted) => {
187
+ sendTemplate: async (to, wanted, idempotencyKey) => {
187
188
  if (!route.sender)
188
189
  throw new Error(`a template needs the channel's sender (set FLOW_MESSAGING_${route.kind.toUpperCase()}_SENDER)`);
189
190
  const found = await template(route.sender, wanted);
190
191
  const params = templateParams(wanted);
191
- await send(route, to, { type: "template", template_id: found.id, language: wanted.language ?? found.language, ...(Object.keys(params).length ? { params } : {}) });
192
+ await send(route, to, { type: "template", template_id: found.id, language: wanted.language ?? found.language, ...(Object.keys(params).length ? { params } : {}) }, undefined, undefined, idempotencyKey);
192
193
  },
193
194
  };
194
195
  }
@@ -232,6 +233,7 @@ export function flowMessaging(options = {}) {
232
233
  ...(outbound.buttons && info.buttons ? { buttons: outbound.buttons } : {}),
233
234
  ...(outbound.link && info.linkButton ? { link: outbound.link } : {}),
234
235
  ...(outbound.image && info.images ? { image: outbound.image } : {}),
236
+ ...(outbound.idempotencyKey ? { idempotencyKey: outbound.idempotencyKey } : {}),
235
237
  });
236
238
  case "reaction":
237
239
  return p.react(to, outbound.messageId, outbound.emoji);
@@ -76,6 +76,8 @@ export interface Message {
76
76
  image?: {
77
77
  url: string;
78
78
  };
79
+ /** Given for a send the business starts (a nudge, a scheduled text): the same in every process, so a provider that takes one sends it once. */
80
+ idempotencyKey?: string;
79
81
  }
80
82
  export interface WhatsAppProvider {
81
83
  sendMessage(to: string, message: Message): Promise<void>;
@@ -90,12 +92,10 @@ export interface WhatsAppProvider {
90
92
  react?(to: string, messageId: string, emoji: string): Promise<void>;
91
93
  typing?(to: string, on: boolean): Promise<void>;
92
94
  markRead?(to: string, messageIds: string[]): Promise<void>;
93
- sendTemplate?(to: string, template: Template): Promise<void>;
95
+ sendTemplate?(to: string, template: Template, idempotencyKey?: string): Promise<void>;
94
96
  }
95
97
  export interface SmsProvider {
96
- sendMessage(to: string, message: {
97
- text: string;
98
- }): Promise<void>;
98
+ sendMessage(to: string, message: Pick<Message, "text" | "idempotencyKey">): Promise<void>;
99
99
  }
100
100
  /** Your own UI. `message` carries the text, any buttons, and your params by name. */
101
101
  export interface CustomUIProvider {
@@ -93,8 +93,8 @@ export function whatsapp(provider, options = {}) {
93
93
  case "template": {
94
94
  if (!provider.sendTemplate || off.has("templates"))
95
95
  throw new Error("this WhatsApp provider sends no templates");
96
- const { type: _type, ...template } = outbound;
97
- return provider.sendTemplate(to, template);
96
+ const { type: _type, idempotencyKey, ...template } = outbound;
97
+ return provider.sendTemplate(to, template, idempotencyKey);
98
98
  }
99
99
  case "status":
100
100
  return;
@@ -112,7 +112,7 @@ export function sms(provider, options = {}) {
112
112
  phone: true,
113
113
  send: (to, outbound) => {
114
114
  if (outbound.type === "message")
115
- return provider.sendMessage(to, { text: outbound.text });
115
+ return provider.sendMessage(to, { text: outbound.text, ...(outbound.idempotencyKey ? { idempotencyKey: outbound.idempotencyKey } : {}) });
116
116
  if (outbound.type === "template")
117
117
  throw new Error("SMS has no templates");
118
118
  // Reactions, typing, read receipts and status do not exist on SMS.
@@ -159,6 +159,7 @@ function message(outbound) {
159
159
  ...(outbound.list ? { list: outbound.list } : {}),
160
160
  ...(outbound.link ? { link: outbound.link } : {}),
161
161
  ...(outbound.image ? { image: outbound.image } : {}),
162
+ ...(outbound.idempotencyKey ? { idempotencyKey: outbound.idempotencyKey } : {}),
162
163
  };
163
164
  }
164
165
  /**
@@ -268,6 +268,7 @@ export declare class Conversation {
268
268
  private send;
269
269
  /** A fixed-text scheduled message or a text or template nudge, sent with no model turn. */
270
270
  private sendDirect;
271
+ private directSettled;
271
272
  /**
272
273
  * The history the model is sent: all of it while it fits the budget, else the latest entries
273
274
  * that do, from the start of one (never between a call and its result), after a line that says
@@ -443,7 +443,7 @@ export class Conversation {
443
443
  }
444
444
  else if (row.kind === "schedule") {
445
445
  if (p.fixed)
446
- this.direct.push({ inbox: row.id, channel: p.channel, text: p.text, run: p.run });
446
+ this.direct.push({ inbox: row.id, channel: p.channel, text: p.text, run: p.run, schedule: p.schedule });
447
447
  else
448
448
  this.notes.push({ text: p.text, inbox: row.id, owed: { run: p.run, mode: p.mode, channel: p.channel } });
449
449
  }
@@ -573,8 +573,14 @@ export class Conversation {
573
573
  this.note(`[Chinwag] ${task.tool} (${task.taskId}) was still running in the background when the agent restarted. It may or may not have finished; check before telling them it is done.`);
574
574
  this.persist(() => store.removeTask(this.id, task.taskId));
575
575
  }
576
- for (const row of await store.openInbox(this.id))
577
- this.enqueue(row);
576
+ for (const row of await store.openInbox(this.id)) {
577
+ // Already in the history (its row was not marked done): answered, so not answered again.
578
+ const p = row.payload;
579
+ if (row.kind === "message" && this.messageIds.get(p.id) === p.channel)
580
+ this.persist(() => store.doneInbox([row.id]));
581
+ else
582
+ this.enqueue(row);
583
+ }
578
584
  const row = await store.conversation(this.id);
579
585
  this.resumeTurn = row?.turnOpen === true;
580
586
  if ((this.resumeTurn || this.hasWork()) && !this.running)
@@ -1136,6 +1142,12 @@ export class Conversation {
1136
1142
  }
1137
1143
  /** A fixed-text scheduled message or a text or template nudge, sent with no model turn. */
1138
1144
  async sendDirect(d) {
1145
+ // Settled already (by an earlier process, or another one running beside this): never sent twice.
1146
+ if (await this.directSettled(d)) {
1147
+ if (d.inbox !== undefined)
1148
+ this.persist(() => this.settings.store.doneInbox([d.inbox]));
1149
+ return;
1150
+ }
1139
1151
  const settle = (status, reason) => {
1140
1152
  this.settle(d, status, reason);
1141
1153
  this.flush([d]);
@@ -1163,7 +1175,9 @@ export class Conversation {
1163
1175
  }
1164
1176
  if (covered)
1165
1177
  return settle("dropped", `no-contact rule ${covered.id}: ${covered.rule}`);
1166
- const outbound = d.template ? { type: "template", ...d.template } : { type: "message", text: d.text ?? "" };
1178
+ // The same key in every process, so the channel's provider sends it once.
1179
+ const idempotencyKey = d.nudge ? `nudge:${d.nudge}` : d.run ? `run:${d.run}` : `inbox:${this.id}:${d.inbox}`;
1180
+ const outbound = d.template ? { type: "template", ...d.template, idempotencyKey } : { type: "message", text: d.text ?? "", idempotencyKey };
1167
1181
  let failure = null;
1168
1182
  if (this.closed)
1169
1183
  return settle("failed", "this person was forgotten");
@@ -1195,6 +1209,18 @@ export class Conversation {
1195
1209
  this.settings.emit(this.id, "message.out", { text: d.text ?? d.template?.name, channel: d.channel, direct: true });
1196
1210
  settle("sent");
1197
1211
  }
1212
+ async directSettled(d) {
1213
+ const { store } = this.settings;
1214
+ if (d.nudge) {
1215
+ const status = (await store.nudge(d.nudge))?.status;
1216
+ return status !== undefined && !["queued", "held", "scheduled"].includes(status);
1217
+ }
1218
+ if (d.run && d.schedule) {
1219
+ const outcome = (await store.runs(d.schedule)).find((r) => r.id === d.run)?.outcome;
1220
+ return outcome !== undefined && outcome !== "queued" && outcome !== "held";
1221
+ }
1222
+ return false;
1223
+ }
1198
1224
  /**
1199
1225
  * The history the model is sent: all of it while it fits the budget, else the latest entries
1200
1226
  * that do, from the start of one (never between a call and its result), after a line that says
@@ -107,6 +107,8 @@ export type Outbound = {
107
107
  };
108
108
  /** The channel's own params (see Channel.params), as the agent filled them in. */
109
109
  fields?: Record<string, unknown>;
110
+ /** The same in every process for one send the business starts (a nudge, a scheduled text), for providers that take one. */
111
+ idempotencyKey?: string;
110
112
  }
111
113
  /** The agent has picked these messages up (WhatsApp's blue ticks). */
112
114
  | {
@@ -122,6 +124,7 @@ export type Outbound = {
122
124
  /** A template the owner set up with the channel (WhatsApp), sent as is, even outside its window. */
123
125
  | ({
124
126
  type: "template";
127
+ idempotencyKey?: string;
125
128
  } & Template)
126
129
  /** A short line of what the agent is doing, shown in the loading indicator; null clears it. */
127
130
  | {
@@ -148,7 +148,9 @@ Each of these can also be passed in code: `flowMessaging({ apiKey, webhookSecret
148
148
  ## What it does for you
149
149
 
150
150
  - Every send carries its own Idempotency-Key. A 429 or 5xx is tried again with the same key after
151
- a short Retry-After, so a message never goes out twice.
151
+ a short Retry-After, so a message never goes out twice. A nudge or a scheduled text keeps one key
152
+ from its id (`nudge:<id>`, `run:<id>`) in every process, so a restart or a second instance does not
153
+ send it again.
152
154
  - Flow's `409 outside_window` becomes the framework's `outside_window`, so the agent knows to send a
153
155
  template.
154
156
  - A permanent refusal (opted out, no consent, ...) reaches the agent once, with "don't resend it".
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flow-engineer/flow",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "An open-source runtime for chat agents that feel like a person texting back.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {