@flow-engineer/flow 0.2.2 → 0.3.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/README.md +3 -3
- package/coding-agents/skill.md +20 -7
- package/coding-agents/test-skill.md +50 -1
- package/dist/flow-messaging/src/client.d.ts +2 -2
- package/dist/flow-messaging/src/client.js +3 -3
- package/dist/flow-messaging/src/index.js +13 -8
- package/dist/simulators/src/server.d.ts +2 -0
- package/dist/simulators/src/server.js +7 -1
- package/dist/src/agent.d.ts +32 -0
- package/dist/src/agent.js +38 -8
- package/dist/src/channels.d.ts +6 -4
- package/dist/src/channels.js +5 -3
- package/dist/src/conversation.d.ts +3 -0
- package/dist/src/conversation.js +37 -5
- package/dist/src/http.js +5 -2
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/local.d.ts +22 -0
- package/dist/src/local.js +85 -0
- package/dist/src/types.d.ts +9 -0
- package/dist/testing/src/account.d.ts +1 -1
- package/dist/testing/src/account.js +78 -20
- package/dist/testing/src/business.d.ts +42 -0
- package/dist/testing/src/business.js +145 -0
- package/dist/testing/src/cli.js +10 -8
- package/dist/testing/src/client.d.ts +4 -2
- package/dist/testing/src/client.js +20 -0
- package/dist/testing/src/index.d.ts +2 -2
- package/dist/testing/src/index.js +1 -1
- package/dist/testing/src/new.d.ts +9 -0
- package/dist/testing/src/new.js +44 -5
- package/dist/testing/src/protocol.d.ts +99 -1
- package/dist/testing/src/protocol.js +9 -0
- package/dist/testing/src/suite.d.ts +142 -3
- package/dist/testing/src/suite.js +94 -10
- package/docs/agents.md +31 -9
- package/examples/cafe/src/main.ts +23 -8
- package/examples/cafe/src/mcp.ts +24 -6
- package/flow-messaging/README.md +3 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,17 +27,17 @@ Node 22.18 or later. Your agent is TypeScript that Node runs as it is, with no b
|
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
29
|
npx @flow-engineer/flow new my-agent --display-name "My agent" && cd my-agent && npm install
|
|
30
|
-
npx @flow-engineer/flow link # signs in; writes Flow's free trial model key and a phone link (or put your own key in .env)
|
|
30
|
+
npx @flow-engineer/flow link --channel telegram # or whatsapp, imessage: signs in; writes Flow's free trial model key and a phone link (or put your own key in .env)
|
|
31
31
|
npm start
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
`flow new <name>` makes `./<name>/` from the café example (lowercase letters, digits and dashes); `--display-name` is what people call it, kept as `displayName` in its `package.json`, and a phone reaches it by sending `Hi <that name> (ref <code>)`. Open the link `npm start` prints and text
|
|
34
|
+
`flow new <name>` makes `./<name>/` from the café example (lowercase letters, digits and dashes); `--display-name` is what people call it, kept as `displayName` in its `package.json`, and a phone reaches it by sending `Hi <that name> (ref <code>)`. Open the link `npm start` prints (it uses `PORT`, else 8788 or the next free port) and text your agent, the café's ordering assistant under the name you gave it (Pip in the example): your starting point; `flow link --display-name <name>` renames it later. Pick a platform at the top (SMS, WhatsApp, iMessage, Telegram, web), and turn on **Debug** to see every tool call and how fast each message was answered. A model call that fails (a wrong or expired key, say) shows in the chat, in Debug and on the terminal. The café's `src/agent.ts` reads its model id from `CAFE_MODEL` (default: the model for the key you set, see [Models](#models)). The new folder has the coding-agent skills in `.claude/skills/` and an `AGENTS.md` pointing at them, so you can tell Claude Code, Codex or any coding agent what to build.
|
|
35
35
|
|
|
36
36
|
### On your phone, from your laptop
|
|
37
37
|
|
|
38
38
|
```bash
|
|
39
39
|
npx @flow-engineer/flow init # the same starter in the current folder; then npm install
|
|
40
|
-
npx @flow-engineer/flow link
|
|
40
|
+
npx @flow-engineer/flow link --channel telegram # signs in (opens the browser), writes its keys and Flow's trial model key into .env, prints a link
|
|
41
41
|
npm start # try it in the playground link it prints, then open the phone link and text the agent
|
|
42
42
|
npx @flow-engineer/flow status # what the folder has and lacks
|
|
43
43
|
```
|
package/coding-agents/skill.md
CHANGED
|
@@ -63,21 +63,25 @@ skill, not in AGENTS.md.
|
|
|
63
63
|
## buildAgent
|
|
64
64
|
|
|
65
65
|
Build the agent in one function that the playground and the tests both call, so they never
|
|
66
|
-
drift apart. Tests pass their own model, events and simulated tools
|
|
66
|
+
drift apart. Tests pass their own model, events and simulated tools; a test the business starts
|
|
67
|
+
also passes `channels` and `testing` (the run's clock), which go on to `createAgent` as they are
|
|
68
|
+
(the `flow-framework-tests` skill):
|
|
67
69
|
|
|
68
70
|
```ts
|
|
69
71
|
import { fileURLToPath } from "node:url";
|
|
70
|
-
import { createAgent, openAICompatible, type EventSink, type Model, type Tool } from "@flow-engineer/flow";
|
|
72
|
+
import { createAgent, openAICompatible, type AgentOptions, type Channel, type EventSink, type Model, type Tool } from "@flow-engineer/flow";
|
|
71
73
|
import { tools as myTools } from "./tools.ts";
|
|
72
74
|
|
|
73
75
|
export const tools = myTools;
|
|
74
76
|
|
|
75
|
-
export function buildAgent(options: { model?: Model; events?: EventSink[]; tools?: Tool[] } = {}) {
|
|
77
|
+
export function buildAgent(options: { model?: Model; events?: EventSink[]; tools?: Tool[]; channels?: Channel[]; testing?: Partial<AgentOptions> } = {}) {
|
|
76
78
|
return createAgent({
|
|
77
79
|
dir: fileURLToPath(new URL("../agent", import.meta.url)),
|
|
78
80
|
model: options.model ?? openAICompatible({ model: "anthropic/claude-haiku-5.5" }),
|
|
79
81
|
tools: options.tools ?? myTools,
|
|
80
82
|
events: options.events ?? [],
|
|
83
|
+
...(options.channels ? { channels: options.channels } : {}),
|
|
84
|
+
...options.testing,
|
|
81
85
|
});
|
|
82
86
|
}
|
|
83
87
|
```
|
|
@@ -93,7 +97,12 @@ and are `message.in`/`message.out` events marked `answered: true`);
|
|
|
93
97
|
`deadlineMs` (default 60,000) is the longest a reply waits for a tool before it moves to the
|
|
94
98
|
background (set a tool's own `deadlineMs` for a known-slow one); `partialAfterMs` (default 5,000)
|
|
95
99
|
is how long a finished tool's result waits for the others of its response; `shareUpdates` turns live status lines on web and app UIs off, or replaces their
|
|
96
|
-
guidance with a string
|
|
100
|
+
guidance with a string; `routes` (`{ "/v1/events": (request) => Response }`) are HTTP routes the
|
|
101
|
+
agent serves beside its chat, such as a business's event push, answered by `agent.fetch(request)`
|
|
102
|
+
(and by the test cloud with a test's events); `onTick(now)` is work of your own on the agent's clock
|
|
103
|
+
(held sends, reminders timed from your data), run with each check for due scheduled messages.
|
|
104
|
+
`agent.reach({ channel, address })` makes the conversation of someone the business may message
|
|
105
|
+
before they have written; until they write, only a template reaches them on WhatsApp.
|
|
97
106
|
|
|
98
107
|
Models are reached through any OpenAI-compatible API. `openAICompatible()` with no key reads the
|
|
99
108
|
first of `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY` and
|
|
@@ -205,6 +214,9 @@ to send later. Prefer `prompt`; use `text` only when the wording must be exact.
|
|
|
205
214
|
see it.", no values) so a nudge is held until they reply instead of refused (`outside_window`); the
|
|
206
215
|
agent may send a fitting template itself (`send_template`), and `template.missing` events say which
|
|
207
216
|
messages want one. Held nudges (quiet hours, a closed window) settle as `held`, then `sent` or `expired`.
|
|
217
|
+
For a call to action use `link` (`text` with `link: { label, url }`, or the agent's send with
|
|
218
|
+
`link`), never a URL in the text: what the business starts goes with link previews off, since on
|
|
219
|
+
WhatsApp a preview card's title becomes the phone's notification instead of the message.
|
|
208
220
|
|
|
209
221
|
When the owner's backend is a separate service, mount `nudgeHandler(agent, { token })` (from
|
|
210
222
|
`@flow-engineer/flow`; a token of at least 32 characters, from a secret; `base` is `/nudges` by default) in the
|
|
@@ -313,12 +325,13 @@ and every simulated message is logged with the person, channel, time and address
|
|
|
313
325
|
To chat with the agent from the owner's phone, with no tunnel and no key made by hand, run these
|
|
314
326
|
from the agent's folder (`npx @flow-engineer/flow init --display-name "<name>"` makes a starter in
|
|
315
327
|
an empty one: first ask the owner what people should call their agent, such as "FlatX agent"; the
|
|
316
|
-
phone's first message is `Hi FlatX agent (ref <code>)`, and `package.json`'s `displayName` keeps it
|
|
328
|
+
phone's first message is `Hi FlatX agent (ref <code>)`, and `package.json`'s `displayName` keeps it;
|
|
329
|
+
`link --display-name "<name>"` renames it later, in `package.json` and `agent/AGENTS.md`).
|
|
317
330
|
Only `chat` reads stdin; `init` asks for the name only in a terminal, and nothing else asks for input.
|
|
318
331
|
|
|
319
332
|
```sh
|
|
320
|
-
npx @flow-engineer/flow link --json
|
|
321
|
-
npm start #
|
|
333
|
+
npx @flow-engineer/flow link --channel telegram --json # or whatsapp, imessage: signs in and runs setup when needed (Flow's trial model key too), then the channel's own link (t.me, wa.me, sms:); give its say_to_person to the owner, and run its next
|
|
334
|
+
npm start # after link: its playground link, and the owner texts it while it runs (PORT, else 8788 or the next free port)
|
|
322
335
|
npx @flow-engineer/flow status --json # what the folder has and lacks, at any time
|
|
323
336
|
printf 'hi\nA latte please\n' | npx @flow-engineer/flow chat # your own check: one message per line, replies printed
|
|
324
337
|
npx @flow-engineer/flow test --synthetic # your own check: the tests, on the key setup wrote
|
|
@@ -74,7 +74,7 @@ expect:
|
|
|
74
74
|
- `timezone` (default UTC) and `now`: the test's clock. `now` is when each run starts,
|
|
75
75
|
`YYYY-MM-DDTHH:MM[:SS]`, local to `timezone` unless it ends in `Z` or an offset (`-04:00`); left
|
|
76
76
|
out, it is the real time. Placeholders are filled from it, and the simulated tools and the judge
|
|
77
|
-
are told it; your agent's clock is not changed: the agent still reads the real time. So set `now` only when the agent takes
|
|
77
|
+
are told it; outside a business run (below) your agent's clock is not changed: the agent still reads the real time. So set `now` only when the agent takes
|
|
78
78
|
its dates from its tools or its context; otherwise leave it out and placeholders follow the real
|
|
79
79
|
date.
|
|
80
80
|
|
|
@@ -98,6 +98,55 @@ time of day, then `| iso` (the default, with its offset), `| date` (2026-10-16),
|
|
|
98
98
|
`person`, `opening`, `expect` and the tool files' `responses`; the cloud fills them on each run. Any other
|
|
99
99
|
`{{...}}` is plain text.
|
|
100
100
|
|
|
101
|
+
### Scenarios the business starts
|
|
102
|
+
|
|
103
|
+
A scenario can start with the business instead of the person: an event its backend sends, a nudge,
|
|
104
|
+
or time passing. Such a run (a business run) gets an agent of its own, built by `buildAgent` with
|
|
105
|
+
the run's clock and the agent's own channels (pass `options.channels` and `options.testing` on to
|
|
106
|
+
`createAgent`, `testing` spread as it is: `now` and `scheduleCheckMs: 0`), so the agent runs on
|
|
107
|
+
the test's clock: quiet hours, held sends, `expiresAt` and caps over days follow it.
|
|
108
|
+
|
|
109
|
+
```yaml
|
|
110
|
+
timezone: Asia/Kolkata
|
|
111
|
+
now: 2026-10-20T23:30
|
|
112
|
+
person:
|
|
113
|
+
address: "+15550100001" # who the events and nudges are for (a made-up number)
|
|
114
|
+
persona: ...
|
|
115
|
+
goal: ...
|
|
116
|
+
opening:
|
|
117
|
+
- event: { type: buying.bid.outbid, delivery: critical, subject: "user:u1", data: { user: { phone: "+15550100001" } } }
|
|
118
|
+
- wait: 8h30m # time passes; what came due runs, a minute at a time
|
|
119
|
+
- say: "ok thanks" # the person can still write
|
|
120
|
+
- event: { id: evt_dup, type: ..., subject: ..., data: {...} }
|
|
121
|
+
signature: bad # or missing; answer: 401
|
|
122
|
+
answer: 401 # what the route must answer (default: any 2xx); { status: 400, field: data.user }
|
|
123
|
+
- nudge: { prompt: "Tell them the listing ends soon." } # or text, or template: { name, body: [...] }
|
|
124
|
+
at: 2026-10-21T15:00 # moves the clock first: a date and time, HH:MM (the next one), or {{now ...}}
|
|
125
|
+
max_turns: 1 # 1: no turns played after the opening
|
|
126
|
+
tools: { real: [keep_me_posted] } # their own code runs, for state the agent keeps itself
|
|
127
|
+
expect:
|
|
128
|
+
sent: # what went out, in order (other sends may come between)
|
|
129
|
+
- { at: "23:30", template: outbid }
|
|
130
|
+
- { at: "08:00", phrased: true, text: "₹53,000" }
|
|
131
|
+
not_sent:
|
|
132
|
+
- { template: outbid_reminder } # {} is anything at all
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
- An `event` is sent exactly as a business's backend sends a Flow event: one envelope
|
|
136
|
+
`{ id, type, occurredAt, subject, delivery?, data }` (`id` and `occurredAt` default to one per
|
|
137
|
+
step and the clock), as `POST /v1/events` (or the step's `path`) to the agent's `routes`, with
|
|
138
|
+
`Content-Type: application/json`, `Idempotency-Key: <id>` and
|
|
139
|
+
`Flow-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.{t}.{body}">`, signed with the
|
|
140
|
+
run's own test secret (`options.testing.eventsSecret`: check signatures with it in a business run).
|
|
141
|
+
The same `id` twice is a duplicate, as it would be live.
|
|
142
|
+
- A `nudge` is `agent.nudge` to the run's person; `wait` and `at` move the clock (never back).
|
|
143
|
+
- `expect.sent` items: `at` (HH:MM on any day, or a date and time; to the minute), `template`
|
|
144
|
+
(its name), `phrased: true` (the agent's own words, not a template), `text` (in the message, or in
|
|
145
|
+
the template's values). Checked by code, like `calls`.
|
|
146
|
+
- A business run where the agent sent nothing is not a failure by itself: `expect` decides.
|
|
147
|
+
- Timers of your own (held sends, reminders timed from your data) go in `createAgent`'s `onTick`,
|
|
148
|
+
which runs on the agent's clock, never in a `setInterval` of your own.
|
|
149
|
+
|
|
101
150
|
How fast the agent first replies to each message is measured on every run from timestamps, not
|
|
102
151
|
written in scenarios: the dashboard shows it for the whole run (average, p95, min and max).
|
|
103
152
|
|
|
@@ -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,
|
|
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,17 @@ 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, channelOptions) {
|
|
101
101
|
try {
|
|
102
102
|
const conversation = await conversationFor(route.name, to, about);
|
|
103
|
+
const options = channelOptions ? { channel_options: channelOptions } : {};
|
|
103
104
|
let sent;
|
|
104
105
|
if (conversation)
|
|
105
|
-
sent = await client.post(`/v1/conversations/${idSegment("conv", conversation)}/messages`, { content, ...(replyTo ? { reply_to: replyTo } : {}) });
|
|
106
|
+
sent = await client.post(`/v1/conversations/${idSegment("conv", conversation)}/messages`, { content, ...(replyTo ? { reply_to: replyTo } : {}), ...options }, undefined, idempotencyKey);
|
|
106
107
|
else {
|
|
107
108
|
if (!route.sender)
|
|
108
109
|
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 });
|
|
110
|
+
sent = await client.post("/v1/messages", { sender: route.sender, to: recipient(route.kind, to), content, ...options }, undefined, idempotencyKey);
|
|
110
111
|
}
|
|
111
112
|
flowConversation.set(key(route.name, to), sent.conversation);
|
|
112
113
|
}
|
|
@@ -175,20 +176,23 @@ export function flowMessaging(options = {}) {
|
|
|
175
176
|
function provider(route) {
|
|
176
177
|
return {
|
|
177
178
|
sendMessage: async (to, message) => {
|
|
179
|
+
// A message the business started: on WhatsApp, no link preview (its card's title would be the phone's notification).
|
|
180
|
+
const options = (content) => (message.preview === false && route.kind === "whatsapp" && content.type === "text" ? { preview_url: false } : undefined);
|
|
178
181
|
const [first, ...rest] = contents(route.kind, message);
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
+
const key = message.idempotencyKey;
|
|
183
|
+
await send(route, to, first, message.replyTo, message.replyTo, key, options(first));
|
|
184
|
+
for (const [i, next] of rest.entries())
|
|
185
|
+
await send(route, to, next, undefined, undefined, key && `${key}:${i + 1}`, options(next));
|
|
182
186
|
},
|
|
183
187
|
react: (to, messageId, emoji) => send(route, to, { type: "reaction", message_id: messageId, emoji }, undefined, messageId),
|
|
184
188
|
typing: (to, on) => courtesy(route, to, "typing", { state: on ? "on" : "off" }),
|
|
185
189
|
markRead: (to, messageIds) => courtesy(route, to, "read", { up_to: messageIds.at(-1) }, messageIds.at(-1)),
|
|
186
|
-
sendTemplate: async (to, wanted) => {
|
|
190
|
+
sendTemplate: async (to, wanted, idempotencyKey) => {
|
|
187
191
|
if (!route.sender)
|
|
188
192
|
throw new Error(`a template needs the channel's sender (set FLOW_MESSAGING_${route.kind.toUpperCase()}_SENDER)`);
|
|
189
193
|
const found = await template(route.sender, wanted);
|
|
190
194
|
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 } : {}) });
|
|
195
|
+
await send(route, to, { type: "template", template_id: found.id, language: wanted.language ?? found.language, ...(Object.keys(params).length ? { params } : {}) }, undefined, undefined, idempotencyKey);
|
|
192
196
|
},
|
|
193
197
|
};
|
|
194
198
|
}
|
|
@@ -232,6 +236,7 @@ export function flowMessaging(options = {}) {
|
|
|
232
236
|
...(outbound.buttons && info.buttons ? { buttons: outbound.buttons } : {}),
|
|
233
237
|
...(outbound.link && info.linkButton ? { link: outbound.link } : {}),
|
|
234
238
|
...(outbound.image && info.images ? { image: outbound.image } : {}),
|
|
239
|
+
...(outbound.idempotencyKey ? { idempotencyKey: outbound.idempotencyKey } : {}),
|
|
235
240
|
});
|
|
236
241
|
case "reaction":
|
|
237
242
|
return p.react(to, outbound.messageId, outbound.emoji);
|
|
@@ -136,6 +136,8 @@ export interface Playground {
|
|
|
136
136
|
}
|
|
137
137
|
export interface TestCloudSimulators {
|
|
138
138
|
events: EventSink;
|
|
139
|
+
/** Where the agent's sends to one person's thread go, before they have written (a run the business starts). */
|
|
140
|
+
deliverer(platform: string, person: string): Deliver;
|
|
139
141
|
listen(agent: AgentConnection, port: number): Promise<Listening>;
|
|
140
142
|
handler(agent: AgentConnection, base?: string): Handler;
|
|
141
143
|
}
|
|
@@ -393,7 +393,9 @@ export function simulators(options = {}) {
|
|
|
393
393
|
}
|
|
394
394
|
function serve(handle, port) {
|
|
395
395
|
const server = createServer(handle);
|
|
396
|
-
return new Promise((resolve) => {
|
|
396
|
+
return new Promise((resolve, reject) => {
|
|
397
|
+
// A port taken since it was chosen rejects, as an error the caller can print, never an unhandled 'error' event.
|
|
398
|
+
server.once("error", reject);
|
|
397
399
|
server.listen(port, "127.0.0.1", () => resolve({
|
|
398
400
|
// The bound port, so port 0 (any free port) works too.
|
|
399
401
|
url: `http://127.0.0.1:${server.address().port}`,
|
|
@@ -537,6 +539,10 @@ export function simulators(options = {}) {
|
|
|
537
539
|
}
|
|
538
540
|
const testCloud = {
|
|
539
541
|
events: cloudEvents,
|
|
542
|
+
deliverer: (platform, person) => {
|
|
543
|
+
const p = platformOf(platform);
|
|
544
|
+
return deliverer(p, key(p.id, person));
|
|
545
|
+
},
|
|
540
546
|
listen: (agent, port) => serve(guarded((req, res) => handle(agent, req, res, "")), port),
|
|
541
547
|
handler: (agent, base = "") => guarded((req, res) => handle(agent, req, res, base)),
|
|
542
548
|
};
|
package/dist/src/agent.d.ts
CHANGED
|
@@ -140,6 +140,18 @@ export interface AgentOptions {
|
|
|
140
140
|
keepMessageIdsDays?: number;
|
|
141
141
|
/** The clock (tests move it). */
|
|
142
142
|
now?: () => number;
|
|
143
|
+
/**
|
|
144
|
+
* Work of your own that runs on the agent's clock: called (and awaited) each time due scheduled
|
|
145
|
+
* messages are looked for (every `scheduleCheckMs`, and each `runDue()`), with the time now. A
|
|
146
|
+
* test that moves the clock runs it at each step, so your own timers (held sends, reminders
|
|
147
|
+
* timed from your data) follow the test's clock. An error is logged, never thrown.
|
|
148
|
+
*/
|
|
149
|
+
onTick?: (now: number) => Promise<void> | void;
|
|
150
|
+
/**
|
|
151
|
+
* HTTP routes the agent serves beside its chat, by path ("/v1/events": a business's event push),
|
|
152
|
+
* answered by `agent.fetch(request)`. The test cloud calls them in-process with a test's events.
|
|
153
|
+
*/
|
|
154
|
+
routes?: Record<string, (request: Request) => Response | Promise<Response>>;
|
|
143
155
|
/**
|
|
144
156
|
* The people who may write from the test playground (`simulate`), and no one else. Each is a
|
|
145
157
|
* real person to the agent, by their phone number: the same conversation whether they write from
|
|
@@ -197,6 +209,11 @@ export interface NudgeInput {
|
|
|
197
209
|
};
|
|
198
210
|
/** Sent as written, with no model call. */
|
|
199
211
|
text?: string;
|
|
212
|
+
/** With `text`: its call to action, sent as a link button (on channels without one, the URL after the text). Never a URL in the text. */
|
|
213
|
+
link?: {
|
|
214
|
+
label: string;
|
|
215
|
+
url: string;
|
|
216
|
+
};
|
|
200
217
|
/** The agent gets a turn with this note and writes the message itself, or sends nothing. */
|
|
201
218
|
prompt?: string;
|
|
202
219
|
/** A template the owner already set up with the channel, sent as is (also outside WhatsApp's window). */
|
|
@@ -515,6 +532,21 @@ export declare class Agent {
|
|
|
515
532
|
}>;
|
|
516
533
|
/** Whether the agent has a profile (get_user_profile), so the test cloud knows to play it. */
|
|
517
534
|
hasProfile(): boolean;
|
|
535
|
+
/**
|
|
536
|
+
* A person the business may message before they have written (they gave it their number): their
|
|
537
|
+
* conversation, made the first time, with no message in it. Until they write, their WhatsApp
|
|
538
|
+
* window is closed, so only a template reaches them there.
|
|
539
|
+
*/
|
|
540
|
+
reach(to: {
|
|
541
|
+
channel: string;
|
|
542
|
+
address: string;
|
|
543
|
+
}): Promise<{
|
|
544
|
+
conversationId: string;
|
|
545
|
+
}>;
|
|
546
|
+
/** A channel the agent was given, by name, as it was given (the test cloud plays a copy of it). */
|
|
547
|
+
channel(name: string): Channel | undefined;
|
|
548
|
+
/** Answers a request to one of `routes` by its path; 404 for any other. */
|
|
549
|
+
fetch(request: Request): Promise<Response>;
|
|
518
550
|
nudge(input: NudgeInput): Promise<NudgeResult>;
|
|
519
551
|
/**
|
|
520
552
|
* Records a nudge and resolves at once, without waiting for its send or turn: `queued`, or what
|
package/dist/src/agent.js
CHANGED
|
@@ -63,6 +63,9 @@ export class Agent {
|
|
|
63
63
|
if (this.channelsByName.has(name))
|
|
64
64
|
throw new Error(`flow: "${name}" is reserved; give the channel another name`);
|
|
65
65
|
this.now = options.now ?? Date.now;
|
|
66
|
+
for (const path of Object.keys(options.routes ?? {}))
|
|
67
|
+
if (!path.startsWith("/"))
|
|
68
|
+
throw new Error(`flow: a route is a path starting with /, not "${path}"`);
|
|
66
69
|
if (options.fallbackTemplate) {
|
|
67
70
|
const declared = declaredTemplate(options.templates, options.fallbackTemplate);
|
|
68
71
|
if (!declared)
|
|
@@ -313,7 +316,7 @@ export class Agent {
|
|
|
313
316
|
// Released because they just wrote: their window is open, though their message may not have landed yet.
|
|
314
317
|
const inbox = await this.store.releaseNudge(writer ? { ...n, body: { ...n.body, theyWrote: true } } : n);
|
|
315
318
|
if (inbox !== null)
|
|
316
|
-
this.wake(n.conversationId, inbox);
|
|
319
|
+
await this.wake(n.conversationId, inbox);
|
|
317
320
|
}
|
|
318
321
|
}
|
|
319
322
|
/** Sends the traces of finished turns still waiting for the traces endpoint; call it before the process exits. */
|
|
@@ -701,6 +704,30 @@ export class Agent {
|
|
|
701
704
|
hasProfile() {
|
|
702
705
|
return this.options.profile !== undefined;
|
|
703
706
|
}
|
|
707
|
+
/**
|
|
708
|
+
* A person the business may message before they have written (they gave it their number): their
|
|
709
|
+
* conversation, made the first time, with no message in it. Until they write, their WhatsApp
|
|
710
|
+
* window is closed, so only a template reaches them there.
|
|
711
|
+
*/
|
|
712
|
+
async reach(to) {
|
|
713
|
+
await this.settings.ready;
|
|
714
|
+
const adapter = this.channelOf(to.channel);
|
|
715
|
+
const identity = await this.conversationFor(adapter, to.channel, to.address);
|
|
716
|
+
const conversation = this.open(identity.conversationId);
|
|
717
|
+
conversation.link({ name: to.channel, info: adapter.info, params: adapter.params, instructions: adapter.instructions, address: to.address, deliver: this.deliverer(adapter, to.address, identity.conversationId), lastMessageAt: identity.lastMessageAt });
|
|
718
|
+
return { conversationId: identity.conversationId };
|
|
719
|
+
}
|
|
720
|
+
/** A channel the agent was given, by name, as it was given (the test cloud plays a copy of it). */
|
|
721
|
+
channel(name) {
|
|
722
|
+
return this.channelsByName.get(name);
|
|
723
|
+
}
|
|
724
|
+
/** Answers a request to one of `routes` by its path; 404 for any other. */
|
|
725
|
+
async fetch(request) {
|
|
726
|
+
const route = this.options.routes?.[new URL(request.url).pathname];
|
|
727
|
+
if (!route)
|
|
728
|
+
return Response.json({ error: "no such route" }, { status: 404 });
|
|
729
|
+
return route(request);
|
|
730
|
+
}
|
|
704
731
|
async nudge(input) {
|
|
705
732
|
// outcome() waits while it is queued, so what it resolves is settled.
|
|
706
733
|
return (await this.outcome(await this.recordNudge(input)));
|
|
@@ -715,6 +742,8 @@ export class Agent {
|
|
|
715
742
|
const kinds = [input.text, input.prompt, input.template].filter((v) => v !== undefined).length;
|
|
716
743
|
if (kinds !== 1)
|
|
717
744
|
throw new Error("flow: a nudge has exactly one of text, prompt or template");
|
|
745
|
+
if (input.link && (input.text === undefined || input.at !== undefined))
|
|
746
|
+
throw new Error("flow: a nudge's link goes with text, sent now (not with `at`)");
|
|
718
747
|
// A declared template whose values don't fit it never goes: Meta would refuse it, the playground would show nonsense.
|
|
719
748
|
const declared = input.template && declaredTemplate(this.options.templates, input.template);
|
|
720
749
|
if (declared)
|
|
@@ -755,7 +784,7 @@ export class Agent {
|
|
|
755
784
|
const added = await add({ ...record, body: { text: input.text, prompt: input.prompt, at: input.at }, status: "scheduled" }, schedule);
|
|
756
785
|
return typeof added === "number" ? { id, status: "scheduled" } : added;
|
|
757
786
|
}
|
|
758
|
-
const body = { address: input.to.address, ...(input.template ? { template: input.template } : input.text !== undefined ? { text: input.text } : { prompt: input.prompt }) };
|
|
787
|
+
const body = { address: input.to.address, ...(input.template ? { template: input.template } : input.text !== undefined ? { text: input.text, ...(input.link ? { link: input.link } : {}) } : { prompt: input.prompt }) };
|
|
759
788
|
const now = this.now();
|
|
760
789
|
const quietEnds = input.respectQuietHours === false ? null : await this.quietEnd(conversationId, now);
|
|
761
790
|
if (quietEnds !== null) {
|
|
@@ -780,7 +809,7 @@ export class Agent {
|
|
|
780
809
|
const added = await add({ ...record, body, status: "queued" });
|
|
781
810
|
if (typeof added !== "number")
|
|
782
811
|
return added;
|
|
783
|
-
this.wake(conversationId, added);
|
|
812
|
+
void this.wake(conversationId, added);
|
|
784
813
|
return { id, status: "queued" };
|
|
785
814
|
}
|
|
786
815
|
/** A nudge's state now, or null for an id the store does not hold. */
|
|
@@ -826,9 +855,11 @@ export class Agent {
|
|
|
826
855
|
return `no way to reach them on ${s.channel}`;
|
|
827
856
|
return windowClosed({ info: this.infoOf(s.channel, s.conversationId), lastMessageAt: identity.lastMessageAt }, now) ? "outside_window" : null;
|
|
828
857
|
});
|
|
829
|
-
for (
|
|
830
|
-
|
|
858
|
+
// Awaited, so a caller that then waits for the conversation (a test moving the clock) sees the turn.
|
|
859
|
+
await Promise.all(queued.map((q) => this.wake(q.conversationId, q.inbox)));
|
|
831
860
|
await this.releaseHeld(now);
|
|
861
|
+
if (this.options.onTick)
|
|
862
|
+
await Promise.resolve().then(() => this.options.onTick(now)).catch((error) => console.warn("flow: onTick:", error));
|
|
832
863
|
await this.pruneMessageIds(now).catch((error) => console.warn("flow: pruning message ids:", error));
|
|
833
864
|
return queued.length;
|
|
834
865
|
}
|
|
@@ -872,14 +903,13 @@ export class Agent {
|
|
|
872
903
|
wake(conversationId, inbox) {
|
|
873
904
|
const resident = this.conversations.get(conversationId);
|
|
874
905
|
if (!resident) {
|
|
875
|
-
|
|
906
|
+
return this.store.conversation(conversationId).then((row) => {
|
|
876
907
|
if (row)
|
|
877
908
|
this.rows.set(conversationId, row);
|
|
878
909
|
this.open(conversationId);
|
|
879
910
|
});
|
|
880
|
-
return;
|
|
881
911
|
}
|
|
882
|
-
|
|
912
|
+
return this.store.openInbox(conversationId).then((rows) => {
|
|
883
913
|
const row = rows.find((r) => r.id === inbox);
|
|
884
914
|
if (row)
|
|
885
915
|
resident.enqueue(row);
|
package/dist/src/channels.d.ts
CHANGED
|
@@ -76,6 +76,10 @@ export interface Message {
|
|
|
76
76
|
image?: {
|
|
77
77
|
url: string;
|
|
78
78
|
};
|
|
79
|
+
/** false: send it with link previews off (a message the business started). */
|
|
80
|
+
preview?: false;
|
|
81
|
+
/** 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. */
|
|
82
|
+
idempotencyKey?: string;
|
|
79
83
|
}
|
|
80
84
|
export interface WhatsAppProvider {
|
|
81
85
|
sendMessage(to: string, message: Message): Promise<void>;
|
|
@@ -90,12 +94,10 @@ export interface WhatsAppProvider {
|
|
|
90
94
|
react?(to: string, messageId: string, emoji: string): Promise<void>;
|
|
91
95
|
typing?(to: string, on: boolean): Promise<void>;
|
|
92
96
|
markRead?(to: string, messageIds: string[]): Promise<void>;
|
|
93
|
-
sendTemplate?(to: string, template: Template): Promise<void>;
|
|
97
|
+
sendTemplate?(to: string, template: Template, idempotencyKey?: string): Promise<void>;
|
|
94
98
|
}
|
|
95
99
|
export interface SmsProvider {
|
|
96
|
-
sendMessage(to: string, message:
|
|
97
|
-
text: string;
|
|
98
|
-
}): Promise<void>;
|
|
100
|
+
sendMessage(to: string, message: Pick<Message, "text" | "idempotencyKey">): Promise<void>;
|
|
99
101
|
}
|
|
100
102
|
/** Your own UI. `message` carries the text, any buttons, and your params by name. */
|
|
101
103
|
export interface CustomUIProvider {
|
package/dist/src/channels.js
CHANGED
|
@@ -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,8 @@ 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.preview === false ? { preview: false } : {}),
|
|
163
|
+
...(outbound.idempotencyKey ? { idempotencyKey: outbound.idempotencyKey } : {}),
|
|
162
164
|
};
|
|
163
165
|
}
|
|
164
166
|
/**
|
|
@@ -117,6 +117,8 @@ export declare class Conversation {
|
|
|
117
117
|
private readonly tasks;
|
|
118
118
|
private taskCount;
|
|
119
119
|
private idleSince;
|
|
120
|
+
/** The turn running now was not started by the person (a nudge, a scheduled message, a background result). */
|
|
121
|
+
private businessTurn;
|
|
120
122
|
private settled;
|
|
121
123
|
/** Channels where nothing has reached the person since their latest message there. */
|
|
122
124
|
private readonly unanswered;
|
|
@@ -268,6 +270,7 @@ export declare class Conversation {
|
|
|
268
270
|
private send;
|
|
269
271
|
/** A fixed-text scheduled message or a text or template nudge, sent with no model turn. */
|
|
270
272
|
private sendDirect;
|
|
273
|
+
private directSettled;
|
|
271
274
|
/**
|
|
272
275
|
* The history the model is sent: all of it while it fits the budget, else the latest entries
|
|
273
276
|
* that do, from the start of one (never between a call and its result), after a line that says
|
package/dist/src/conversation.js
CHANGED
|
@@ -101,6 +101,8 @@ export class Conversation {
|
|
|
101
101
|
tasks = new Map();
|
|
102
102
|
taskCount = 0;
|
|
103
103
|
idleSince = Date.now();
|
|
104
|
+
/** The turn running now was not started by the person (a nudge, a scheduled message, a background result). */
|
|
105
|
+
businessTurn = false;
|
|
104
106
|
settled = [];
|
|
105
107
|
/** Channels where nothing has reached the person since their latest message there. */
|
|
106
108
|
unanswered = new Set();
|
|
@@ -443,7 +445,7 @@ export class Conversation {
|
|
|
443
445
|
}
|
|
444
446
|
else if (row.kind === "schedule") {
|
|
445
447
|
if (p.fixed)
|
|
446
|
-
this.direct.push({ inbox: row.id, channel: p.channel, text: p.text, run: p.run });
|
|
448
|
+
this.direct.push({ inbox: row.id, channel: p.channel, text: p.text, run: p.run, schedule: p.schedule });
|
|
447
449
|
else
|
|
448
450
|
this.notes.push({ text: p.text, inbox: row.id, owed: { run: p.run, mode: p.mode, channel: p.channel } });
|
|
449
451
|
}
|
|
@@ -463,7 +465,7 @@ export class Conversation {
|
|
|
463
465
|
this.notes.push({ text, inbox: row.id, owed: { nudge: p.nudge, mode: "prompt", channel: p.channel, address: p.address } });
|
|
464
466
|
}
|
|
465
467
|
else
|
|
466
|
-
this.direct.push({ inbox: row.id, channel: p.channel, address: p.address, text: p.text, template: p.template, nudge: p.nudge, ...(p.theyWrote ? { theyWrote: true } : {}) });
|
|
468
|
+
this.direct.push({ inbox: row.id, channel: p.channel, address: p.address, text: p.text, ...(p.link ? { link: p.link } : {}), template: p.template, nudge: p.nudge, ...(p.theyWrote ? { theyWrote: true } : {}) });
|
|
467
469
|
}
|
|
468
470
|
if (!this.running && this.hasWork())
|
|
469
471
|
this.schedule(0);
|
|
@@ -573,8 +575,14 @@ export class Conversation {
|
|
|
573
575
|
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
576
|
this.persist(() => store.removeTask(this.id, task.taskId));
|
|
575
577
|
}
|
|
576
|
-
for (const row of await store.openInbox(this.id))
|
|
577
|
-
|
|
578
|
+
for (const row of await store.openInbox(this.id)) {
|
|
579
|
+
// Already in the history (its row was not marked done): answered, so not answered again.
|
|
580
|
+
const p = row.payload;
|
|
581
|
+
if (row.kind === "message" && this.messageIds.get(p.id) === p.channel)
|
|
582
|
+
this.persist(() => store.doneInbox([row.id]));
|
|
583
|
+
else
|
|
584
|
+
this.enqueue(row);
|
|
585
|
+
}
|
|
578
586
|
const row = await store.conversation(this.id);
|
|
579
587
|
this.resumeTurn = row?.turnOpen === true;
|
|
580
588
|
if ((this.resumeTurn || this.hasWork()) && !this.running)
|
|
@@ -797,6 +805,7 @@ export class Conversation {
|
|
|
797
805
|
this.absorb();
|
|
798
806
|
// A turn the person started (as against a background result, a scheduled message or a nudge).
|
|
799
807
|
const theirs = this.unanswered.size > 0;
|
|
808
|
+
this.businessTurn = !theirs;
|
|
800
809
|
if (!this.channels.size) {
|
|
801
810
|
// No way to reach them right now (a channel with no adapter, after a restart).
|
|
802
811
|
for (const o of this.owed.splice(0))
|
|
@@ -1043,6 +1052,8 @@ export class Conversation {
|
|
|
1043
1052
|
...(input.link ? { link: input.link } : {}),
|
|
1044
1053
|
...(input.image ? { image: input.image } : {}),
|
|
1045
1054
|
...(hasFields ? { fields } : {}),
|
|
1055
|
+
// A turn the person did not start: no link preview leads the notification (types.ts, `preview`).
|
|
1056
|
+
...(this.businessTurn ? { preview: false } : {}),
|
|
1046
1057
|
});
|
|
1047
1058
|
const failure = await this.send(channel, outbound);
|
|
1048
1059
|
if (failure) {
|
|
@@ -1136,6 +1147,12 @@ export class Conversation {
|
|
|
1136
1147
|
}
|
|
1137
1148
|
/** A fixed-text scheduled message or a text or template nudge, sent with no model turn. */
|
|
1138
1149
|
async sendDirect(d) {
|
|
1150
|
+
// Settled already (by an earlier process, or another one running beside this): never sent twice.
|
|
1151
|
+
if (await this.directSettled(d)) {
|
|
1152
|
+
if (d.inbox !== undefined)
|
|
1153
|
+
this.persist(() => this.settings.store.doneInbox([d.inbox]));
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1139
1156
|
const settle = (status, reason) => {
|
|
1140
1157
|
this.settle(d, status, reason);
|
|
1141
1158
|
this.flush([d]);
|
|
@@ -1163,7 +1180,10 @@ export class Conversation {
|
|
|
1163
1180
|
}
|
|
1164
1181
|
if (covered)
|
|
1165
1182
|
return settle("dropped", `no-contact rule ${covered.id}: ${covered.rule}`);
|
|
1166
|
-
|
|
1183
|
+
// The business started it: its link goes as a button, and no preview leads the notification.
|
|
1184
|
+
// The same key in every process, so the channel's provider sends it once.
|
|
1185
|
+
const idempotencyKey = d.nudge ? `nudge:${d.nudge}` : d.run ? `run:${d.run}` : `inbox:${this.id}:${d.inbox}`;
|
|
1186
|
+
const outbound = d.template ? { type: "template", ...d.template, idempotencyKey } : fitMessage(link.info, { type: "message", text: d.text ?? "", ...(d.link ? { link: d.link } : {}), preview: false, idempotencyKey });
|
|
1167
1187
|
let failure = null;
|
|
1168
1188
|
if (this.closed)
|
|
1169
1189
|
return settle("failed", "this person was forgotten");
|
|
@@ -1195,6 +1215,18 @@ export class Conversation {
|
|
|
1195
1215
|
this.settings.emit(this.id, "message.out", { text: d.text ?? d.template?.name, channel: d.channel, direct: true });
|
|
1196
1216
|
settle("sent");
|
|
1197
1217
|
}
|
|
1218
|
+
async directSettled(d) {
|
|
1219
|
+
const { store } = this.settings;
|
|
1220
|
+
if (d.nudge) {
|
|
1221
|
+
const status = (await store.nudge(d.nudge))?.status;
|
|
1222
|
+
return status !== undefined && !["queued", "held", "scheduled"].includes(status);
|
|
1223
|
+
}
|
|
1224
|
+
if (d.run && d.schedule) {
|
|
1225
|
+
const outcome = (await store.runs(d.schedule)).find((r) => r.id === d.run)?.outcome;
|
|
1226
|
+
return outcome !== undefined && outcome !== "queued" && outcome !== "held";
|
|
1227
|
+
}
|
|
1228
|
+
return false;
|
|
1229
|
+
}
|
|
1198
1230
|
/**
|
|
1199
1231
|
* The history the model is sent: all of it while it fits the budget, else the latest entries
|
|
1200
1232
|
* that do, from the start of one (never between a call and its result), after a line that says
|