@flow-engineer/flow 0.0.1 → 0.2.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/LICENSE +202 -0
- package/README.md +611 -0
- package/coding-agents/skill.md +353 -0
- package/coding-agents/test-skill.md +223 -0
- package/coding-agents/tools-skill.md +89 -0
- package/dist/flow-messaging/src/client.d.ts +40 -0
- package/dist/flow-messaging/src/client.js +142 -0
- package/dist/flow-messaging/src/index.d.ts +72 -0
- package/dist/flow-messaging/src/index.js +605 -0
- package/dist/simulators/src/index.d.ts +2 -0
- package/dist/simulators/src/index.js +2 -0
- package/dist/simulators/src/page.html +1245 -0
- package/dist/simulators/src/person.html +100 -0
- package/dist/simulators/src/platforms.d.ts +29 -0
- package/dist/simulators/src/platforms.js +72 -0
- package/dist/simulators/src/server.d.ts +201 -0
- package/dist/simulators/src/server.js +759 -0
- package/dist/src/agent.d.ts +504 -0
- package/dist/src/agent.js +818 -0
- package/dist/src/channels.d.ts +178 -0
- package/dist/src/channels.js +243 -0
- package/dist/src/conversation.d.ts +361 -0
- package/dist/src/conversation.js +1487 -0
- package/dist/src/db.d.ts +18 -0
- package/dist/src/db.js +57 -0
- package/dist/src/events.d.ts +17 -0
- package/dist/src/events.js +20 -0
- package/dist/src/http.d.ts +45 -0
- package/dist/src/http.js +269 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +11 -0
- package/dist/src/model.d.ts +21 -0
- package/dist/src/model.js +96 -0
- package/dist/src/names.d.ts +22 -0
- package/dist/src/names.js +43 -0
- package/dist/src/nudges.d.ts +23 -0
- package/dist/src/nudges.js +117 -0
- package/dist/src/postgres.d.ts +20 -0
- package/dist/src/postgres.js +62 -0
- package/dist/src/schedules.d.ts +60 -0
- package/dist/src/schedules.js +249 -0
- package/dist/src/skills.d.ts +12 -0
- package/dist/src/skills.js +69 -0
- package/dist/src/store.d.ts +214 -0
- package/dist/src/store.js +448 -0
- package/dist/src/talk.d.ts +57 -0
- package/dist/src/talk.js +166 -0
- package/dist/src/time.d.ts +71 -0
- package/dist/src/time.js +170 -0
- package/dist/src/tools.d.ts +43 -0
- package/dist/src/tools.js +26 -0
- package/dist/src/traces.d.ts +31 -0
- package/dist/src/traces.js +303 -0
- package/dist/src/types.d.ts +260 -0
- package/dist/src/types.js +2 -0
- package/dist/testing/src/account.d.ts +4 -0
- package/dist/testing/src/account.js +580 -0
- package/dist/testing/src/chat.d.ts +3 -0
- package/dist/testing/src/chat.js +117 -0
- package/dist/testing/src/check-endpoint.d.ts +31 -0
- package/dist/testing/src/check-endpoint.js +158 -0
- package/dist/testing/src/chinwag.d.ts +2 -0
- package/dist/testing/src/chinwag.js +6 -0
- package/dist/testing/src/cli.d.ts +2 -0
- package/dist/testing/src/cli.js +592 -0
- package/dist/testing/src/client.d.ts +117 -0
- package/dist/testing/src/client.js +956 -0
- package/dist/testing/src/flow-login.d.ts +143 -0
- package/dist/testing/src/flow-login.js +249 -0
- package/dist/testing/src/http-agent.d.ts +47 -0
- package/dist/testing/src/http-agent.js +155 -0
- package/dist/testing/src/index.d.ts +3 -0
- package/dist/testing/src/index.js +2 -0
- package/dist/testing/src/new.d.ts +13 -0
- package/dist/testing/src/new.js +147 -0
- package/dist/testing/src/print-run.d.ts +3 -0
- package/dist/testing/src/print-run.js +22 -0
- package/dist/testing/src/protocol.d.ts +159 -0
- package/dist/testing/src/protocol.js +28 -0
- package/dist/testing/src/report.d.ts +74 -0
- package/dist/testing/src/report.js +90 -0
- package/dist/testing/src/show.d.ts +36 -0
- package/dist/testing/src/show.js +280 -0
- package/dist/testing/src/suite.d.ts +87 -0
- package/dist/testing/src/suite.js +198 -0
- package/docs/agents.md +85 -0
- package/examples/cafe/agent/AGENTS.md +10 -0
- package/examples/cafe/agent/skills/allergens/SKILL.md +7 -0
- package/examples/cafe/src/agent.ts +43 -0
- package/examples/cafe/src/channels.ts +20 -0
- package/examples/cafe/src/main.ts +30 -0
- package/examples/cafe/src/tools.ts +62 -0
- package/examples/cafe/tests/scenarios/latte-correction.yaml +30 -0
- package/examples/cafe/tests/scenarios/order-fails.yaml +26 -0
- package/examples/cafe/tests/tools/get_menu.yaml +14 -0
- package/examples/cafe/tests/tools/place_order.yaml +29 -0
- package/examples/http-agent/server.mjs +32 -0
- package/examples/http-agent/tests/scenarios/opening-hours.yaml +16 -0
- package/flow-messaging/README.md +191 -0
- package/package.json +63 -8
- package/spec/agent-endpoint.openapi.yaml +148 -0
- package/spec/nudges.openapi.yaml +167 -0
- package/spec/request.schema.json +121 -0
- package/spec/response.schema.json +284 -0
|
@@ -0,0 +1,1487 @@
|
|
|
1
|
+
// One conversation: an actor that owns one person's history, across every channel they write
|
|
2
|
+
// on, and runs one turn at a time.
|
|
3
|
+
//
|
|
4
|
+
// - Typing shows the moment a message arrives; it stops once the person has been acknowledged.
|
|
5
|
+
// - Double texts fold into the reply in progress. Someone who sends another message is usually
|
|
6
|
+
// correcting themselves, so a model step in flight is aborted and runs again with every message
|
|
7
|
+
// so far; one that arrives while tools run ends the wait for them (below).
|
|
8
|
+
// - The person sees only what the agent sends with its send tools, react or share_updates.
|
|
9
|
+
// - Acknowledgement first: after the person writes, the agent's first response must reach them,
|
|
10
|
+
// on every channel they are waiting on. A first response that only calls tools is held, and the
|
|
11
|
+
// agent is asked to acknowledge in the same response as those calls.
|
|
12
|
+
// - Tools in one response run together, and the reply waits for all of them: one answer, not an
|
|
13
|
+
// acknowledgement and a result later. A tool moves to the background (the agent is told, the
|
|
14
|
+
// person is not) only when the person writes again meanwhile, when another tool of the response
|
|
15
|
+
// finished partialAfterMs ago (the finished results go back now), or past its own limit
|
|
16
|
+
// (deadlineMs). Its result comes back into this chat later; the same call with the same input
|
|
17
|
+
// while it runs is not run again. Messages go out in the order the agent wrote them, while tools run.
|
|
18
|
+
// - A sent message's result says it was delivered. A reply to the person of only messages, in a turn
|
|
19
|
+
// where the agent used no tool, gets one more step (it may have said "getting your plan" and meant
|
|
20
|
+
// to act next); a second such response ends the turn. A message that fails to arrive comes back to the agent as
|
|
21
|
+
// a message.
|
|
22
|
+
// - A background result the agent says nothing about, with no other tool pending, is sent back
|
|
23
|
+
// once: it may have written the answer as plain text, which never reaches the person. A result
|
|
24
|
+
// returned to its response is sent back only when the agent answers it in new plain text: silence
|
|
25
|
+
// is left alone, and text repeating a message already sent counts as said.
|
|
26
|
+
// - Several channels: each message is tagged with its channel, the agent answers each on its own
|
|
27
|
+
// channel, and a send on a channel missing from their last 5 messages is held until the agent
|
|
28
|
+
// confirms it.
|
|
29
|
+
// - Everything is kept in the store as it happens: the history, the inbox of events not yet
|
|
30
|
+
// handled, background tools in flight. After a restart the conversation carries on.
|
|
31
|
+
// - Scheduled messages and nudges arrive through the inbox and wait for the turn in progress.
|
|
32
|
+
// - A message the agent's own rules answer with no model call (its intercept, a STOP, START or
|
|
33
|
+
// HELP keyword) is kept with what was sent back, in order with the person's other messages, and
|
|
34
|
+
// starts no turn: a turn running takes it in at its next step, else it is stored at once.
|
|
35
|
+
// - Files the person sends: photos and PDFs go to the model when it reads them, a voice note as its
|
|
36
|
+
// transcript; anything it cannot read is named with why, so it can ask for text. Only the latest
|
|
37
|
+
// few are shown, and their bytes are never stored: after a restart they are named, not shown.
|
|
38
|
+
import { Buffer } from "node:buffer";
|
|
39
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
40
|
+
import { z } from "zod";
|
|
41
|
+
import { declaredTemplate, fillTemplate, fitMessage } from "./channels.js";
|
|
42
|
+
import { coveredBy, noContactTools, rulesSection } from "./nudges.js";
|
|
43
|
+
import { scheduleTools } from "./schedules.js";
|
|
44
|
+
import { REACT, UPDATE, reactInput, sendInput, sendRefusal, sendToolName, talkInstructions, talkSpecs, updateInput } from "./talk.js";
|
|
45
|
+
import { formatLocal } from "./time.js";
|
|
46
|
+
import { render, tool, toSpec } from "./tools.js";
|
|
47
|
+
const ACKNOWLEDGE_FIRST = "[Chinwag] They have not heard from you yet. In one response, acknowledge them " +
|
|
48
|
+
"(react to their message or send a short line) and make your tool calls again.";
|
|
49
|
+
const INTERRUPTED = "[Chinwag] Cut off by a restart: this may or may not have happened. Check before you rely on it.";
|
|
50
|
+
const APOLOGY = "Sorry, something went wrong on my side. Could you send that again?";
|
|
51
|
+
const EARLIER = "[Chinwag] Older messages of this conversation are no longer shown to you.";
|
|
52
|
+
const NOT_SHOWN = "[Chinwag] You are no longer shown the photos or documents in these messages; ask them to send one again if you need it.";
|
|
53
|
+
/** Photos and PDFs the model is shown at once, the latest; each is resent on every model call. */
|
|
54
|
+
const MEDIA_SHOWN = 3;
|
|
55
|
+
/** The largest photo, and the largest PDF or voice note, the agent reads (most models take 5 MB images). */
|
|
56
|
+
const IMAGE_MAX_MB = 5;
|
|
57
|
+
const FILE_MAX_MB = 10;
|
|
58
|
+
/** The image types the main model APIs take. */
|
|
59
|
+
const IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
|
|
60
|
+
export class Conversation {
|
|
61
|
+
id;
|
|
62
|
+
personKey;
|
|
63
|
+
history = [];
|
|
64
|
+
settings;
|
|
65
|
+
channels = new Map();
|
|
66
|
+
pending = [];
|
|
67
|
+
notes = [];
|
|
68
|
+
/** A tool's result is in the history and no message has gone out since: from the background, or returned to a response. */
|
|
69
|
+
resultUnsent = false;
|
|
70
|
+
/** What the agent has sent in this turn, to tell its own text repeated from something new. */
|
|
71
|
+
sentThisTurn = new Set();
|
|
72
|
+
direct = [];
|
|
73
|
+
owed = [];
|
|
74
|
+
/** The person's message ids, and the channel each came on. */
|
|
75
|
+
messageIds = new Map();
|
|
76
|
+
knownInbox = new Set();
|
|
77
|
+
messageCount = 0;
|
|
78
|
+
/** Turns begun, kept in the store: tools' idempotency keys carry the turn's number. */
|
|
79
|
+
turns = 0;
|
|
80
|
+
/** Aborted when the conversation closes: tools' signal. */
|
|
81
|
+
lifetime = new AbortController();
|
|
82
|
+
recentChannels = [];
|
|
83
|
+
timeZone = null;
|
|
84
|
+
/** Their own quiet hours ("off": none), or null for the agent's. */
|
|
85
|
+
quietHours = null;
|
|
86
|
+
running = false;
|
|
87
|
+
/** Messages whose files are still being read (downloaded, transcribed); later ones wait behind them. */
|
|
88
|
+
preparing = null;
|
|
89
|
+
stepAbort = null;
|
|
90
|
+
/** Ends the reply's wait for its tools now (the person wrote). */
|
|
91
|
+
wakeTools = null;
|
|
92
|
+
timer = null;
|
|
93
|
+
tasks = new Map();
|
|
94
|
+
taskCount = 0;
|
|
95
|
+
idleSince = Date.now();
|
|
96
|
+
settled = [];
|
|
97
|
+
/** Channels where nothing has reached the person since their latest message there. */
|
|
98
|
+
unanswered = new Set();
|
|
99
|
+
/** The next model response is the first since their latest message. */
|
|
100
|
+
firstResponse = false;
|
|
101
|
+
typingOn = new Set();
|
|
102
|
+
/** The send tool for each channel, this turn. */
|
|
103
|
+
sendTools = new Map();
|
|
104
|
+
builtins;
|
|
105
|
+
builtinSpecs;
|
|
106
|
+
loaded;
|
|
107
|
+
writes;
|
|
108
|
+
/** History entries already in the store. */
|
|
109
|
+
saved = 0;
|
|
110
|
+
/** A turn was cut off by a restart: it runs again with the history so far. */
|
|
111
|
+
resumeTurn = false;
|
|
112
|
+
/** Forgotten: nothing more runs, is sent or is kept. */
|
|
113
|
+
closed = false;
|
|
114
|
+
/** This turn took in a message from the person: if it fails, they are told. */
|
|
115
|
+
theirMessages = false;
|
|
116
|
+
/** History entries left out of the latest model call (0: none), so a change is an event once. */
|
|
117
|
+
leftOut = 0;
|
|
118
|
+
/** They texted STOP (as of the latest look): what the business starts is dropped, with no model call. */
|
|
119
|
+
stop = null;
|
|
120
|
+
constructor(id, personKey, settings, existing = null) {
|
|
121
|
+
this.id = id;
|
|
122
|
+
this.personKey = personKey;
|
|
123
|
+
this.settings = settings;
|
|
124
|
+
if (existing) {
|
|
125
|
+
this.messageCount = existing.messageCount;
|
|
126
|
+
this.turns = existing.turns;
|
|
127
|
+
this.recentChannels = existing.recentChannels;
|
|
128
|
+
this.timeZone = existing.timeZone;
|
|
129
|
+
this.quietHours = existing.quietHours;
|
|
130
|
+
}
|
|
131
|
+
const builtins = [
|
|
132
|
+
...scheduleTools({
|
|
133
|
+
store: settings.store,
|
|
134
|
+
conversationId: id,
|
|
135
|
+
channel: () => this.currentChannel(),
|
|
136
|
+
now: settings.now,
|
|
137
|
+
personZone: () => this.timeZone,
|
|
138
|
+
setPersonZone: (zone) => this.setTimeZone(zone, "person"),
|
|
139
|
+
agentZone: settings.timeZone,
|
|
140
|
+
}),
|
|
141
|
+
...noContactTools({ store: settings.store, conversationId: id, now: settings.now, zone: () => this.timeZone, setQuietHours: (q) => this.setQuietHours(q) }),
|
|
142
|
+
];
|
|
143
|
+
const fallback = settings.fallbackTemplate;
|
|
144
|
+
const offered = settings.templates.filter((t) => t.name !== fallback);
|
|
145
|
+
if (offered.length)
|
|
146
|
+
builtins.push(this.templateTool(offered));
|
|
147
|
+
this.builtins = new Map(builtins.map((t) => [t.name, t]));
|
|
148
|
+
this.builtinSpecs = builtins.map(toSpec);
|
|
149
|
+
this.loaded = settings.ready.then(() => (existing ? this.load() : settings.store.createConversation(id, personKey)));
|
|
150
|
+
this.writes = this.loaded.catch((error) => console.warn("flow: conversation %s did not load:", id, error));
|
|
151
|
+
}
|
|
152
|
+
/** Their time zone, as the business set it or they said it: schedules, quiet hours and the time line use it from now on. */
|
|
153
|
+
setTimeZone(zone, source) {
|
|
154
|
+
this.timeZone = zone;
|
|
155
|
+
return this.persist(() => this.settings.store.updateConversation(this.id, { timeZone: zone, timeZoneSource: source }));
|
|
156
|
+
}
|
|
157
|
+
/** Their own quiet hours ("off": none; null: back to the agent's). */
|
|
158
|
+
setQuietHours(hours) {
|
|
159
|
+
this.quietHours = hours;
|
|
160
|
+
return this.persist(() => this.settings.store.updateConversation(this.id, { quietHours: hours }));
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* send_template: the agent sends one of the business's WhatsApp templates, its values filled, when
|
|
164
|
+
* Flow Framework tells it their 24-hour window is closed. The fallback template is Flow Framework's to send.
|
|
165
|
+
*/
|
|
166
|
+
templateTool(offered) {
|
|
167
|
+
const names = offered.map((t) => t.name);
|
|
168
|
+
return tool({
|
|
169
|
+
name: "send_template",
|
|
170
|
+
description: `Send one of the business's WhatsApp templates, only when Chinwag says their 24-hour window is closed. Templates: ${offered.map((t) => `${t.name}: "${t.body}"`).join("; ")}. body: the {{1}}, {{2}}... values in order.`,
|
|
171
|
+
input: z.object({
|
|
172
|
+
name: z.enum(names),
|
|
173
|
+
body: z.array(z.string()).optional(),
|
|
174
|
+
header: z.string().optional().describe("The header's {{1}}, when its header is text with a value"),
|
|
175
|
+
}),
|
|
176
|
+
run: async ({ name, body, header }, context) => {
|
|
177
|
+
const template = { name, ...(body?.length ? { body } : {}), ...(header ? { header: { kind: "text", text: header } } : {}) };
|
|
178
|
+
const declared = declaredTemplate(this.settings.templates, template);
|
|
179
|
+
let reads = "";
|
|
180
|
+
try {
|
|
181
|
+
if (declared)
|
|
182
|
+
reads = fillTemplate(declared, template).body;
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
return { error: error instanceof Error ? error.message.replace(/^flow: /, "") : String(error) };
|
|
186
|
+
}
|
|
187
|
+
const channel = this.owed.find((o) => o.windowClosed)?.channel ?? context.channel;
|
|
188
|
+
const failure = await this.send(channel, { type: "template", ...template });
|
|
189
|
+
if (failure)
|
|
190
|
+
return { error: failure };
|
|
191
|
+
this.settings.emit(this.id, "message.out", { text: name, channel, template: true });
|
|
192
|
+
this.history.push({ role: "user", content: `[Chinwag] You sent them the template "${name}" on ${channel}. It reads:\n${reads}` });
|
|
193
|
+
this.sent();
|
|
194
|
+
return { delivered: true, channel };
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
/** What a channel of this conversation can show, if the person has written on it. */
|
|
199
|
+
channelInfo(channel) {
|
|
200
|
+
return this.channels.get(channel)?.info;
|
|
201
|
+
}
|
|
202
|
+
/** Reach the person on this channel (again): a new channel, or a fresh way to deliver. */
|
|
203
|
+
link(link) {
|
|
204
|
+
const known = this.channels.get(link.name);
|
|
205
|
+
if (known) {
|
|
206
|
+
known.deliver = link.deliver;
|
|
207
|
+
known.info = link.info;
|
|
208
|
+
known.params = link.params;
|
|
209
|
+
known.instructions = link.instructions;
|
|
210
|
+
known.address = link.address;
|
|
211
|
+
}
|
|
212
|
+
else
|
|
213
|
+
this.channels.set(link.name, link);
|
|
214
|
+
}
|
|
215
|
+
/** A message from the person on `channel`; returns the id the agent sees it by. */
|
|
216
|
+
receive(input, options) {
|
|
217
|
+
const incoming = typeof input === "string" ? { text: input } : input;
|
|
218
|
+
const channel = options.channel;
|
|
219
|
+
if (options.messageId !== undefined && this.messageIds.get(options.messageId) === channel) {
|
|
220
|
+
// The channel gave this message before (a retry): it is taken once.
|
|
221
|
+
this.settings.emit(this.id, "message.duplicate", { messageId: options.messageId, channel });
|
|
222
|
+
return options.messageId;
|
|
223
|
+
}
|
|
224
|
+
const id = options.messageId ?? `m${++this.messageCount}`;
|
|
225
|
+
this.messageIds.set(id, channel);
|
|
226
|
+
void this.typing(true, channel);
|
|
227
|
+
// A file or voice note is read before message.in. Say the message arrived first, so a test
|
|
228
|
+
// run is not settled as silence while the transcript is still being made.
|
|
229
|
+
if (incoming.attachments?.length)
|
|
230
|
+
this.settings.emit(this.id, "message.arrived", { messageId: id, channel });
|
|
231
|
+
if (!incoming.attachments?.length && !this.preparing) {
|
|
232
|
+
this.arrive(incoming, id, channel, options.messageId !== undefined, { marks: [], media: [] });
|
|
233
|
+
return id;
|
|
234
|
+
}
|
|
235
|
+
// Its files are read first (a voice note transcribed), and messages after it wait their turn.
|
|
236
|
+
const ready = (this.preparing ?? Promise.resolve())
|
|
237
|
+
.then(async () => {
|
|
238
|
+
const files = await this.readFiles(incoming.attachments ?? []);
|
|
239
|
+
if (!this.closed)
|
|
240
|
+
this.arrive(incoming, id, channel, options.messageId !== undefined, files);
|
|
241
|
+
})
|
|
242
|
+
.catch((error) => console.warn("flow: a message in conversation %s could not be read:", this.id, error));
|
|
243
|
+
this.preparing = ready;
|
|
244
|
+
void ready.finally(() => {
|
|
245
|
+
if (this.preparing !== ready)
|
|
246
|
+
return;
|
|
247
|
+
this.preparing = null;
|
|
248
|
+
this.checkIdle();
|
|
249
|
+
});
|
|
250
|
+
return id;
|
|
251
|
+
}
|
|
252
|
+
/** The message is ready for the agent: it joins the reply in progress, or starts one. */
|
|
253
|
+
arrive(incoming, id, channel, ownId, files) {
|
|
254
|
+
const at = this.settings.now();
|
|
255
|
+
const item = { id, text: describe(incoming, files), shown: shownText(incoming, files), ...(incoming.choice ? { choice: true } : {}), channel, ...(files.media.length ? { media: files.media } : {}) };
|
|
256
|
+
this.pending.push(item);
|
|
257
|
+
this.recentChannels = [...this.recentChannels, channel].slice(-5);
|
|
258
|
+
const link = this.channels.get(channel);
|
|
259
|
+
if (link)
|
|
260
|
+
link.lastMessageAt = at;
|
|
261
|
+
const fields = { messageCount: this.messageCount, recentChannels: this.recentChannels };
|
|
262
|
+
this.persist(async () => {
|
|
263
|
+
const payload = { id, text: item.text, shown: item.shown, ...(item.choice ? { choice: true } : {}), channel, ...(item.media ? { media: item.media.map(stored) } : {}) };
|
|
264
|
+
const inbox = await this.settings.store.addMessage(this.id, payload, ownId ? { channel, messageId: id } : undefined);
|
|
265
|
+
if (inbox === null) {
|
|
266
|
+
// Another runner took this provider id first (a retry reached both): the message is theirs.
|
|
267
|
+
this.pending = this.pending.filter((waiting) => waiting !== item);
|
|
268
|
+
this.settings.emit(this.id, "message.duplicate", { messageId: id, channel });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
item.inbox = inbox;
|
|
272
|
+
this.knownInbox.add(item.inbox);
|
|
273
|
+
await this.settings.store.updateConversation(this.id, fields);
|
|
274
|
+
if (link)
|
|
275
|
+
await this.settings.store.touchIdentity(channel, link.address, at);
|
|
276
|
+
});
|
|
277
|
+
const attachments = incoming.attachments?.map((a) => ({ kind: a.kind, mime: a.mime, ...(a.name ? { name: a.name } : {}) }));
|
|
278
|
+
this.settings.emit(this.id, "message.in", { text: incoming.text, messageId: id, ownId, channel, replyTo: incoming.replyTo, choice: incoming.choice, ...(attachments ? { attachments } : {}), ...(files.transcript !== undefined ? { transcript: files.transcript } : {}) });
|
|
279
|
+
this.unanswered.add(channel);
|
|
280
|
+
this.firstResponse = true;
|
|
281
|
+
void this.typing(true, channel);
|
|
282
|
+
if (this.running) {
|
|
283
|
+
// Fold it in: a model step in flight is thrown away and run again with this message, and
|
|
284
|
+
// tools being waited on move to the background so the agent reads it now.
|
|
285
|
+
this.stepAbort?.abort();
|
|
286
|
+
this.wakeTools?.();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
this.schedule(this.settings.debounceMs);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* A message the agent's own rules answered with no model call (its intercept, a STOP, START or
|
|
293
|
+
* HELP keyword), and `reply`, what goes back to them ("" for nothing). Both join the history in
|
|
294
|
+
* order with their other messages, at the next step of a turn running now or else at once, and
|
|
295
|
+
* start no turn. Null when the channel gave this message before.
|
|
296
|
+
*
|
|
297
|
+
* `taken` is false when its provider id was taken first (by another runner, or before the STOP
|
|
298
|
+
* written in `claimed`, which already holds the id): nothing is kept then. `send` delivers the
|
|
299
|
+
* reply, unless it was not taken; it starts the delivery at once, so call it once `taken` where
|
|
300
|
+
* a retry must not be answered twice.
|
|
301
|
+
*/
|
|
302
|
+
answered(input, options, reply) {
|
|
303
|
+
const incoming = typeof input === "string" ? { text: input } : input;
|
|
304
|
+
const { channel, messageId } = options;
|
|
305
|
+
if (messageId !== undefined && this.messageIds.get(messageId) === channel) {
|
|
306
|
+
this.settings.emit(this.id, "message.duplicate", { messageId, channel });
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
const id = messageId ?? `m${++this.messageCount}`;
|
|
310
|
+
this.messageIds.set(id, channel);
|
|
311
|
+
const none = { marks: [], media: [] };
|
|
312
|
+
const answer = { reply, ...(options.note ? { note: options.note } : {}) };
|
|
313
|
+
const item = { id, text: describe(incoming, none), shown: shownText(incoming, none), ...(incoming.choice ? { choice: true } : {}), channel, answer };
|
|
314
|
+
this.pending.push(item);
|
|
315
|
+
const at = this.settings.now();
|
|
316
|
+
this.recentChannels = [...this.recentChannels, channel].slice(-5);
|
|
317
|
+
const link = this.channels.get(channel);
|
|
318
|
+
if (link)
|
|
319
|
+
link.lastMessageAt = at;
|
|
320
|
+
const fields = { messageCount: this.messageCount, recentChannels: this.recentChannels };
|
|
321
|
+
const ownId = messageId !== undefined;
|
|
322
|
+
let lost = false;
|
|
323
|
+
const stored = this.persist(async () => {
|
|
324
|
+
if (options.claimed && !(await options.claimed))
|
|
325
|
+
return void (lost = true);
|
|
326
|
+
const payload = { id, text: item.text, shown: item.shown, ...(item.choice ? { choice: true } : {}), channel, answer };
|
|
327
|
+
// Its provider id is written with it (unless the STOP already holds it), so a retry is not answered again.
|
|
328
|
+
const inbox = await this.settings.store.addMessage(this.id, payload, ownId && !options.claimed ? { channel, messageId: id } : undefined);
|
|
329
|
+
if (inbox === null)
|
|
330
|
+
return void (lost = true);
|
|
331
|
+
item.inbox = inbox;
|
|
332
|
+
this.knownInbox.add(inbox);
|
|
333
|
+
await this.settings.store.updateConversation(this.id, fields);
|
|
334
|
+
if (link)
|
|
335
|
+
await this.settings.store.touchIdentity(channel, link.address, at);
|
|
336
|
+
});
|
|
337
|
+
const taken = stored.then(() => {
|
|
338
|
+
if (this.closed)
|
|
339
|
+
return false;
|
|
340
|
+
if (lost) {
|
|
341
|
+
this.pending = this.pending.filter((waiting) => waiting !== item);
|
|
342
|
+
this.settings.emit(this.id, "message.duplicate", { messageId: id, channel });
|
|
343
|
+
this.checkIdle();
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
this.settings.emit(this.id, "message.in", { text: incoming.text, messageId: id, ownId, channel, replyTo: incoming.replyTo, choice: incoming.choice, answered: true });
|
|
347
|
+
return true;
|
|
348
|
+
});
|
|
349
|
+
const send = async () => {
|
|
350
|
+
const failure = reply ? await this.send(channel, { type: "message", text: reply }) : null;
|
|
351
|
+
if (!(await taken))
|
|
352
|
+
return;
|
|
353
|
+
if (failure) {
|
|
354
|
+
// Kept as answered with nothing, if it is not in the history yet; the agent hears it did not arrive.
|
|
355
|
+
answer.reply = "";
|
|
356
|
+
this.undelivered(reply, failure);
|
|
357
|
+
}
|
|
358
|
+
else if (reply)
|
|
359
|
+
this.settings.emit(this.id, "message.out", { text: reply, channel, direct: true, answered: true });
|
|
360
|
+
if (!this.running && !this.timer)
|
|
361
|
+
void this.run();
|
|
362
|
+
};
|
|
363
|
+
return { id, taken, send };
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* What the agent gets of the person's files: photos and PDFs the model reads, a voice note as its
|
|
367
|
+
* transcript, and for anything else a mark saying why it cannot be read, so it can ask for text.
|
|
368
|
+
*/
|
|
369
|
+
async readFiles(attachments) {
|
|
370
|
+
const files = { marks: [], media: [] };
|
|
371
|
+
const reads = this.settings.model.reads ?? {};
|
|
372
|
+
for (const a of attachments) {
|
|
373
|
+
const name = a.name ? ` "${a.name.replace(/["\n]/g, "")}"` : "";
|
|
374
|
+
const what = a.kind === "image" ? "photo" : a.kind === "audio" ? "voice note" : `document${name}`;
|
|
375
|
+
const cannot = a.kind === "image" ? (!reads.images ? "you can't see photos" : !IMAGE_TYPES.has(a.mime) ? "you can't see this type of image" : null)
|
|
376
|
+
: a.kind === "document" ? (a.mime !== "application/pdf" ? "you can only open PDFs" : !reads.pdfs ? "you can't open documents" : null)
|
|
377
|
+
: !this.settings.transcribe ? "you can't listen to voice notes" : null;
|
|
378
|
+
if (cannot) {
|
|
379
|
+
files.marks.push(`[${what}: ${cannot}]`);
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const max = a.kind === "image" ? IMAGE_MAX_MB : FILE_MAX_MB;
|
|
383
|
+
let bytes;
|
|
384
|
+
try {
|
|
385
|
+
bytes = await a.data();
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
this.settings.emit(this.id, "attachment.failed", { kind: a.kind, error: String(error) });
|
|
389
|
+
files.marks.push(`[${what}: it could not be downloaded]`);
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (bytes.length > max * 1_048_576) {
|
|
393
|
+
files.marks.push(`[${what}: too large for you to read, over ${max} MB]`);
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
if (a.kind !== "audio") {
|
|
397
|
+
files.media.push({ kind: a.kind, mime: a.mime, ...(a.name ? { name: a.name } : {}), data: Buffer.from(bytes).toString("base64") });
|
|
398
|
+
files.marks.push(`[${what}]`);
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
const text = (await this.settings.transcribe({ mime: a.mime, data: bytes })).trim();
|
|
403
|
+
files.transcript = [files.transcript, text].filter(Boolean).join("\n");
|
|
404
|
+
files.marks.push("[voice note, transcribed]");
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
this.settings.emit(this.id, "attachment.failed", { kind: a.kind, error: String(error) });
|
|
408
|
+
files.marks.push(`[${what}: it could not be transcribed]`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return files;
|
|
412
|
+
}
|
|
413
|
+
/** An event from the inbox (a scheduled run, a nudge) for this conversation. */
|
|
414
|
+
enqueue(row) {
|
|
415
|
+
if (this.knownInbox.has(row.id))
|
|
416
|
+
return;
|
|
417
|
+
this.knownInbox.add(row.id);
|
|
418
|
+
const p = row.payload;
|
|
419
|
+
if (row.kind === "message") {
|
|
420
|
+
// Its photos and PDFs are named, not shown: their bytes were never stored.
|
|
421
|
+
this.pending.push({ id: p.id, text: p.text, shown: p.shown ?? p.text, ...(p.choice ? { choice: true } : {}), channel: p.channel, inbox: row.id, ...(Array.isArray(p.media) ? { media: p.media } : {}), ...(p.answer ? { answer: p.answer } : {}) });
|
|
422
|
+
this.messageIds.set(p.id, p.channel);
|
|
423
|
+
if (!p.answer) {
|
|
424
|
+
this.unanswered.add(p.channel);
|
|
425
|
+
this.firstResponse = true;
|
|
426
|
+
this.wakeTools?.();
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
else if (row.kind === "note") {
|
|
430
|
+
this.notes.push({ text: p.text, inbox: row.id, wait: p.wait === true });
|
|
431
|
+
}
|
|
432
|
+
else if (row.kind === "schedule") {
|
|
433
|
+
if (p.fixed)
|
|
434
|
+
this.direct.push({ inbox: row.id, channel: p.channel, text: p.text, run: p.run });
|
|
435
|
+
else
|
|
436
|
+
this.notes.push({ text: p.text, inbox: row.id, owed: { run: p.run, mode: p.mode, channel: p.channel } });
|
|
437
|
+
}
|
|
438
|
+
else if (row.kind === "nudge") {
|
|
439
|
+
if (p.prompt !== undefined) {
|
|
440
|
+
if (p.windowClosed) {
|
|
441
|
+
const text = `[Nudge from the business, not from the person: ${p.prompt}] Their WhatsApp 24-hour window is closed: no ordinary message reaches them. If one of the business's templates says this, send it with send_template, its values filled in. If none fits, send nothing: Flow Framework sends them a "you have a new message" template and gives you this again when they reply.`;
|
|
442
|
+
this.notes.push({ text, inbox: row.id, owed: { nudge: p.nudge, mode: "prompt", channel: p.channel, windowClosed: true } });
|
|
443
|
+
if (!this.running && this.hasWork())
|
|
444
|
+
this.schedule(0);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const held = typeof p.heldAt === "number" ? `held for their quiet hours since ${formatLocal(this.timeZone ?? this.settings.timeZone ?? "UTC", p.heldAt)}; ` : "";
|
|
448
|
+
const text = held
|
|
449
|
+
? `[Nudge from the business, not from the person, ${held}${p.prompt}] Tell them now in your own words. Others held with it come in this turn too: cover them all in one short message, not a text each, and answer anything they just asked first.`
|
|
450
|
+
: `[Nudge from the business, not from the person: ${p.prompt}] Write it to them now in your own words, or send nothing if it does not fit what they asked not to be messaged about.`;
|
|
451
|
+
this.notes.push({ text, inbox: row.id, owed: { nudge: p.nudge, mode: "prompt", channel: p.channel } });
|
|
452
|
+
}
|
|
453
|
+
else
|
|
454
|
+
this.direct.push({ inbox: row.id, channel: p.channel, text: p.text, template: p.template, nudge: p.nudge, ...(p.theyWrote ? { theyWrote: true } : {}) });
|
|
455
|
+
}
|
|
456
|
+
if (!this.running && this.hasWork())
|
|
457
|
+
this.schedule(0);
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* The person reacted to one of the agent's messages. The agent learns it at its next turn; a
|
|
461
|
+
* reaction alone does not start one.
|
|
462
|
+
*/
|
|
463
|
+
reacted(emoji, toText) {
|
|
464
|
+
this.settings.emit(this.id, "reaction.in", { emoji, toText });
|
|
465
|
+
this.note(`[They reacted ${emoji} to your message: "${clip(toText)}"]`, true);
|
|
466
|
+
}
|
|
467
|
+
/** The channel reports, now or later, that a message did not reach the person. */
|
|
468
|
+
undelivered(text, reason) {
|
|
469
|
+
this.settings.emit(this.id, "message.undelivered", { text, reason });
|
|
470
|
+
this.note(`[Not delivered] "${text}": ${reason}`);
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* They texted a STOP or START keyword: their STOP is kept (or lifted) with no model call, with
|
|
474
|
+
* the message's provider id. Returns the line the agent learns it by, kept with the message
|
|
475
|
+
* (`answered`), and `stored`, false when that id was taken before.
|
|
476
|
+
*/
|
|
477
|
+
keyword(kind, text, channel, messageId) {
|
|
478
|
+
const { store } = this.settings;
|
|
479
|
+
const inbound = messageId === undefined ? undefined : { channel, messageId };
|
|
480
|
+
this.settings.emit(this.id, "keyword", { keyword: kind, text, channel });
|
|
481
|
+
let taken = true;
|
|
482
|
+
const keep = (rule) => this.persist(async () => void (taken = await store.setStop(this.id, rule, inbound))).then(() => taken);
|
|
483
|
+
if (kind === "stop") {
|
|
484
|
+
const rule = { id: `nc_stop_${randomUUID().slice(0, 6)}`, rule: `nothing the business starts: they texted ${text.trim()} on ${channel}; only START lifts it`, until: null, hard: true, kind: "stop" };
|
|
485
|
+
this.stop = rule;
|
|
486
|
+
return { note: `[Chinwag] They texted "${clip(text.trim())}" on ${channel}: nothing the business starts (nudges, scheduled messages) reaches them until they text START. Answer them when they write.`, stored: keep(rule) };
|
|
487
|
+
}
|
|
488
|
+
this.stop = null;
|
|
489
|
+
return { note: `[Chinwag] They texted "${clip(text.trim())}" on ${channel}: messages the business starts may reach them again.`, stored: keep(null) };
|
|
490
|
+
}
|
|
491
|
+
get busy() {
|
|
492
|
+
// A message answered with no model call waits in `pending` for its reply to go and its place in the history.
|
|
493
|
+
return this.running || this.timer !== null || this.tasks.size > 0 || this.preparing !== null || this.pending.length > 0;
|
|
494
|
+
}
|
|
495
|
+
get idleForMs() {
|
|
496
|
+
return this.busy ? 0 : Date.now() - this.idleSince;
|
|
497
|
+
}
|
|
498
|
+
get backgroundTasks() {
|
|
499
|
+
return [...this.tasks.values()];
|
|
500
|
+
}
|
|
501
|
+
/** Resolves once nothing is running or waiting (no turn, no background task) and all is stored. */
|
|
502
|
+
async whenIdle() {
|
|
503
|
+
if (this.busy)
|
|
504
|
+
await new Promise((resolve) => this.settled.push(resolve));
|
|
505
|
+
await this.writes;
|
|
506
|
+
}
|
|
507
|
+
/** Resolves once everything so far is in the store. */
|
|
508
|
+
stored() {
|
|
509
|
+
return this.writes;
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* The person was forgotten: a step in flight is cut off, nothing waiting runs, nothing more is
|
|
513
|
+
* sent or kept. Tools already running finish on their own, and their results go nowhere.
|
|
514
|
+
*/
|
|
515
|
+
close() {
|
|
516
|
+
this.closed = true;
|
|
517
|
+
if (this.timer)
|
|
518
|
+
clearTimeout(this.timer);
|
|
519
|
+
this.timer = null;
|
|
520
|
+
this.pending = [];
|
|
521
|
+
this.notes = [];
|
|
522
|
+
this.direct = [];
|
|
523
|
+
this.stepAbort?.abort();
|
|
524
|
+
this.wakeTools?.();
|
|
525
|
+
this.lifetime.abort();
|
|
526
|
+
}
|
|
527
|
+
/** Runs `work` after every earlier write to the store, in order. */
|
|
528
|
+
persist(work) {
|
|
529
|
+
if (this.closed)
|
|
530
|
+
return this.writes;
|
|
531
|
+
const next = this.writes.then(work).then(() => undefined, (error) => console.warn("flow: could not save conversation %s:", this.id, error));
|
|
532
|
+
this.writes = next;
|
|
533
|
+
return next;
|
|
534
|
+
}
|
|
535
|
+
/** Picks up where the conversation was: its history, events not yet handled, tools cut off. */
|
|
536
|
+
async load() {
|
|
537
|
+
const { store } = this.settings;
|
|
538
|
+
for (const link of await this.settings.links(this.id))
|
|
539
|
+
this.link(link);
|
|
540
|
+
const history = await store.history(this.id);
|
|
541
|
+
this.saved = history.length;
|
|
542
|
+
this.history.push(...history);
|
|
543
|
+
const only = [...this.channels.keys()][0] ?? "chat";
|
|
544
|
+
for (const entry of history) {
|
|
545
|
+
if (entry.role !== "user")
|
|
546
|
+
continue;
|
|
547
|
+
for (const m of personMessages(entry, only))
|
|
548
|
+
this.messageIds.set(m.id, m.channel);
|
|
549
|
+
}
|
|
550
|
+
// A step cut off while its tools ran: those calls get a result that says so.
|
|
551
|
+
const answered = new Set(history.flatMap((e) => (e.role === "tool" ? [e.toolCallId] : [])));
|
|
552
|
+
const lastCalls = [...history].reverse().find((e) => e.role === "assistant" && e.toolCalls?.length);
|
|
553
|
+
if (lastCalls?.role === "assistant") {
|
|
554
|
+
for (const c of lastCalls.toolCalls ?? [])
|
|
555
|
+
if (!answered.has(c.id))
|
|
556
|
+
this.history.push({ role: "tool", toolCallId: c.id, content: INTERRUPTED });
|
|
557
|
+
}
|
|
558
|
+
for (const task of await store.tasks(this.id)) {
|
|
559
|
+
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.`);
|
|
560
|
+
this.persist(() => store.removeTask(this.id, task.taskId));
|
|
561
|
+
}
|
|
562
|
+
for (const row of await store.openInbox(this.id))
|
|
563
|
+
this.enqueue(row);
|
|
564
|
+
const row = await store.conversation(this.id);
|
|
565
|
+
this.resumeTurn = row?.turnOpen === true;
|
|
566
|
+
if ((this.resumeTurn || this.hasWork()) && !this.running)
|
|
567
|
+
this.schedule(0);
|
|
568
|
+
}
|
|
569
|
+
/** Messages from the person the agent has yet to answer (not those answered with no model call). */
|
|
570
|
+
unread() {
|
|
571
|
+
return this.pending.some((p) => !p.answer);
|
|
572
|
+
}
|
|
573
|
+
hasWork() {
|
|
574
|
+
return this.pending.length > 0 || this.notes.some((n) => !n.wait) || this.direct.length > 0;
|
|
575
|
+
}
|
|
576
|
+
note(text, wait = false) {
|
|
577
|
+
const item = { text, wait };
|
|
578
|
+
this.notes.push(item);
|
|
579
|
+
this.persist(async () => {
|
|
580
|
+
item.inbox = await this.settings.store.addInbox(this.id, "note", { text, wait });
|
|
581
|
+
this.knownInbox.add(item.inbox);
|
|
582
|
+
});
|
|
583
|
+
if (!wait && !this.running)
|
|
584
|
+
this.schedule(0);
|
|
585
|
+
}
|
|
586
|
+
schedule(delayMs) {
|
|
587
|
+
if (this.closed)
|
|
588
|
+
return;
|
|
589
|
+
if (this.timer)
|
|
590
|
+
clearTimeout(this.timer);
|
|
591
|
+
this.timer = setTimeout(() => {
|
|
592
|
+
this.timer = null;
|
|
593
|
+
void this.run();
|
|
594
|
+
}, delayMs);
|
|
595
|
+
}
|
|
596
|
+
async run() {
|
|
597
|
+
if (this.running)
|
|
598
|
+
return;
|
|
599
|
+
this.running = true;
|
|
600
|
+
try {
|
|
601
|
+
await this.loaded;
|
|
602
|
+
for (;;) {
|
|
603
|
+
if (this.closed)
|
|
604
|
+
break;
|
|
605
|
+
if (this.unread() || this.notes.some((n) => !n.wait) || this.resumeTurn) {
|
|
606
|
+
try {
|
|
607
|
+
await this.turn();
|
|
608
|
+
}
|
|
609
|
+
catch (error) {
|
|
610
|
+
await this.turnFailed(error);
|
|
611
|
+
}
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
// Only messages answered with no model call: into the history, with no turn.
|
|
615
|
+
if (this.pending.length) {
|
|
616
|
+
this.absorb();
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
const next = this.direct.shift();
|
|
620
|
+
if (!next)
|
|
621
|
+
break;
|
|
622
|
+
try {
|
|
623
|
+
await this.sendDirect(next);
|
|
624
|
+
}
|
|
625
|
+
catch (error) {
|
|
626
|
+
this.settings.emit(this.id, "model.error", { error: String(error) });
|
|
627
|
+
this.settle(next, "failed", errorText(error));
|
|
628
|
+
this.flush([next]);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
finally {
|
|
633
|
+
this.running = false;
|
|
634
|
+
for (const channel of [...this.typingOn])
|
|
635
|
+
await this.typing(false, channel);
|
|
636
|
+
for (const link of this.channels.values())
|
|
637
|
+
if (link.info.status)
|
|
638
|
+
await this.send(link.name, { type: "status", text: null });
|
|
639
|
+
this.checkIdle();
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* A turn threw (the model failed): what it owed is settled as failed, what was waiting is taken
|
|
644
|
+
* in so it is not tried again, and only a person waiting on a reply is told something went wrong.
|
|
645
|
+
* A turn they did not start (a nudge, a scheduled message, a background result) stays silent.
|
|
646
|
+
*/
|
|
647
|
+
async turnFailed(error) {
|
|
648
|
+
this.settings.emit(this.id, "model.error", { error: String(error) });
|
|
649
|
+
this.absorb();
|
|
650
|
+
for (const o of this.owed.splice(0))
|
|
651
|
+
this.settle(o, "failed", `the agent's turn failed: ${errorText(error)}`);
|
|
652
|
+
this.resultUnsent = false;
|
|
653
|
+
this.persist(() => this.settings.store.updateConversation(this.id, { turnOpen: false }));
|
|
654
|
+
const waiting = this.theirMessages ? [...this.unanswered] : [];
|
|
655
|
+
if (waiting.length)
|
|
656
|
+
await this.send(waiting.includes(this.currentChannel()) ? this.currentChannel() : waiting[0], { type: "message", text: APOLOGY });
|
|
657
|
+
}
|
|
658
|
+
checkIdle() {
|
|
659
|
+
if (this.busy)
|
|
660
|
+
return;
|
|
661
|
+
this.idleSince = Date.now();
|
|
662
|
+
const waiting = this.settled;
|
|
663
|
+
this.settled = [];
|
|
664
|
+
for (const resolve of waiting)
|
|
665
|
+
resolve();
|
|
666
|
+
}
|
|
667
|
+
views() {
|
|
668
|
+
return [...this.channels.values()].map((c) => ({ name: c.name, info: c.info, params: c.params, instructions: c.instructions }));
|
|
669
|
+
}
|
|
670
|
+
/** The channel of the person's latest message. */
|
|
671
|
+
currentChannel() {
|
|
672
|
+
return this.recentChannels.at(-1) ?? [...this.channels.keys()][0] ?? "chat";
|
|
673
|
+
}
|
|
674
|
+
/** Move waiting messages and notes into the history; the person's messages are now read. */
|
|
675
|
+
absorb() {
|
|
676
|
+
if (!this.pending.length && !this.notes.length)
|
|
677
|
+
return false;
|
|
678
|
+
const items = [];
|
|
679
|
+
for (const note of this.notes.splice(0)) {
|
|
680
|
+
if (note.owed && this.stop) {
|
|
681
|
+
// They texted STOP: what the business started never reaches the model.
|
|
682
|
+
this.settle(note.owed, "dropped", `no-contact rule ${this.stop.id}: ${this.stop.rule}`);
|
|
683
|
+
items.push(note);
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
this.history.push({ role: "user", content: note.text });
|
|
687
|
+
if (note.text.startsWith("[Background result:"))
|
|
688
|
+
this.resultUnsent = "background";
|
|
689
|
+
if (note.owed)
|
|
690
|
+
this.owed.push(note.owed);
|
|
691
|
+
items.push(note);
|
|
692
|
+
}
|
|
693
|
+
const messages = this.pending.splice(0);
|
|
694
|
+
if (messages.length) {
|
|
695
|
+
const tag = this.settings.tagChannels || this.channels.size > 1;
|
|
696
|
+
const line = (m) => `[${tag ? `${m.channel} · ` : ""}${m.id}${m.text}`;
|
|
697
|
+
const said = (m) => ({ id: m.id, channel: m.channel, text: m.shown, ...(m.choice ? { choice: true } : {}) });
|
|
698
|
+
// In order: the messages the agent answers, together in one entry, and each answered with no
|
|
699
|
+
// model call as one of its own, with what was sent back as the agent's (as a text nudge is).
|
|
700
|
+
let group = [];
|
|
701
|
+
const read = () => {
|
|
702
|
+
if (!group.length)
|
|
703
|
+
return;
|
|
704
|
+
const media = group.flatMap((m) => m.media ?? []);
|
|
705
|
+
this.history.push({ role: "user", content: group.map(line).join("\n"), ...(media.length ? { media } : {}), messages: group.map(said) });
|
|
706
|
+
if (media.length)
|
|
707
|
+
this.showLatestMedia();
|
|
708
|
+
group = [];
|
|
709
|
+
};
|
|
710
|
+
for (const m of messages) {
|
|
711
|
+
if (!m.answer) {
|
|
712
|
+
group.push(m);
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
read();
|
|
716
|
+
const how = m.answer.reply ? `[Chinwag] The business's own rules answered this for you on ${m.channel}, as written:` : `[Chinwag] The business's own rules took this message for you on ${m.channel} and sent nothing back.`;
|
|
717
|
+
this.history.push({ role: "user", content: [line(m), m.answer.note, how].filter(Boolean).join("\n"), messages: [said(m)] });
|
|
718
|
+
if (m.answer.reply) {
|
|
719
|
+
const callId = `answered_${randomUUID().slice(0, 8)}`;
|
|
720
|
+
this.history.push({ role: "assistant", content: null, toolCalls: [{ id: callId, name: sendToolName(m.channel), arguments: JSON.stringify({ text: m.answer.reply }) }] });
|
|
721
|
+
this.history.push({ role: "tool", toolCallId: callId, content: JSON.stringify({ delivered: true, channel: m.channel }) });
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
read();
|
|
725
|
+
items.push(...messages);
|
|
726
|
+
const theirs = messages.filter((m) => !m.answer);
|
|
727
|
+
if (theirs.length)
|
|
728
|
+
this.theirMessages = true;
|
|
729
|
+
for (const channel of new Set(theirs.map((m) => m.channel))) {
|
|
730
|
+
if (this.channels.get(channel)?.info.readReceipts)
|
|
731
|
+
void this.send(channel, { type: "read", messageIds: theirs.filter((m) => m.channel === channel).map((m) => m.id) });
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
this.flush(items);
|
|
735
|
+
return true;
|
|
736
|
+
}
|
|
737
|
+
/** Stores history entries not yet stored, and marks the inbox rows they came from handled. */
|
|
738
|
+
flush(items = []) {
|
|
739
|
+
const seq = this.saved;
|
|
740
|
+
const entries = this.history.slice(seq).map((e) => (e.role === "user" && e.media ? { ...e, media: e.media.map(stored) } : e));
|
|
741
|
+
this.saved = this.history.length;
|
|
742
|
+
if (!entries.length && !items.length)
|
|
743
|
+
return;
|
|
744
|
+
this.persist(() => this.settings.store.appendHistory(this.id, seq, entries, items.flatMap((i) => (i.inbox === undefined ? [] : [i.inbox]))));
|
|
745
|
+
}
|
|
746
|
+
/** Only the latest MEDIA_SHOWN photos and PDFs keep their bytes; older ones are named, not shown. */
|
|
747
|
+
showLatestMedia() {
|
|
748
|
+
let shown = 0;
|
|
749
|
+
for (let i = this.history.length - 1; i >= 0; i--) {
|
|
750
|
+
const entry = this.history[i];
|
|
751
|
+
if (entry.role !== "user" || !entry.media)
|
|
752
|
+
continue;
|
|
753
|
+
for (let k = entry.media.length - 1; k >= 0; k--) {
|
|
754
|
+
if (!entry.media[k].data)
|
|
755
|
+
continue;
|
|
756
|
+
if (++shown > MEDIA_SHOWN)
|
|
757
|
+
entry.media[k] = stored(entry.media[k]);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
async turn() {
|
|
762
|
+
const started = Date.now();
|
|
763
|
+
const { store } = this.settings;
|
|
764
|
+
const noContact = await store.noContact(this.id, this.settings.now());
|
|
765
|
+
this.stop = noContact.find((r) => r.hard) ?? null;
|
|
766
|
+
if (this.stop && !this.resumeTurn && !this.unread() && !this.notes.some((n) => !n.wait && !n.owed)) {
|
|
767
|
+
// Only what the business started is waiting, and they texted STOP: it is dropped, no turn runs.
|
|
768
|
+
this.absorb();
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
// A turn cut off by a restart keeps its number, so a tool called again in it gets the same key.
|
|
772
|
+
if (!this.resumeTurn)
|
|
773
|
+
this.turns++;
|
|
774
|
+
const turns = this.turns;
|
|
775
|
+
this.resumeTurn = false;
|
|
776
|
+
this.theirMessages = false;
|
|
777
|
+
this.settings.emit(this.id, "turn.start", {});
|
|
778
|
+
this.persist(() => store.updateConversation(this.id, { turnOpen: true, turns }));
|
|
779
|
+
this.absorb();
|
|
780
|
+
// A turn the person started (as against a background result, a scheduled message or a nudge).
|
|
781
|
+
const theirs = this.unanswered.size > 0;
|
|
782
|
+
if (!this.channels.size) {
|
|
783
|
+
// No way to reach them right now (a channel with no adapter, after a restart).
|
|
784
|
+
for (const o of this.owed.splice(0))
|
|
785
|
+
this.settle(o, "failed", "no way to reach them on that channel right now");
|
|
786
|
+
this.persist(() => store.updateConversation(this.id, { turnOpen: false }));
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
const context = this.settings.context ? await this.settings.context(this.id) : "";
|
|
790
|
+
if (context.trim()) {
|
|
791
|
+
const event = { type: "turn.context", conversationId: this.id, at: Date.now(), text: context };
|
|
792
|
+
for (const sink of this.settings.contextSinks) {
|
|
793
|
+
try {
|
|
794
|
+
sink(event);
|
|
795
|
+
}
|
|
796
|
+
catch (error) {
|
|
797
|
+
console.warn("flow: an event sink failed:", error);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
const quiet = this.quietHours === "off" ? null : (this.quietHours ?? (this.settings.quietHours === false ? null : (this.settings.quietHours ?? { from: "22:00", to: "07:00" })));
|
|
802
|
+
const rules = rulesSection(noContact, this.timeZone ?? this.settings.timeZone ?? "UTC", this.timeZone ? quiet : null);
|
|
803
|
+
// A message that came while those loaded had no step to restart: it joins the first one.
|
|
804
|
+
this.absorb();
|
|
805
|
+
let restarts = 0;
|
|
806
|
+
let reminded = false;
|
|
807
|
+
let followedUp = false;
|
|
808
|
+
/** A tool other than talking has been called in this turn. */
|
|
809
|
+
let acted = false;
|
|
810
|
+
this.sentThisTurn.clear();
|
|
811
|
+
for (let step = 0; step < this.settings.maxSteps; step++) {
|
|
812
|
+
if (this.closed)
|
|
813
|
+
return;
|
|
814
|
+
// The channels can grow mid-turn (they wrote on another one), so each step offers what is there now.
|
|
815
|
+
const views = this.views();
|
|
816
|
+
const several = views.length > 1;
|
|
817
|
+
this.sendTools = new Map(views.map((v) => [sendToolName(v.name), v.name]));
|
|
818
|
+
const system = [this.settings.instructions, talkInstructions(views, this.settings.talk), rules, nowLine(this.settings.now(), this.timeZone, this.settings.timeZone), context].filter(Boolean).join("\n\n");
|
|
819
|
+
const specs = [...this.settings.specs, ...this.builtinSpecs, ...talkSpecs(views, this.settings.talk)];
|
|
820
|
+
const abort = new AbortController();
|
|
821
|
+
if (restarts < this.settings.maxRestarts)
|
|
822
|
+
this.stepAbort = abort;
|
|
823
|
+
const callStarted = Date.now();
|
|
824
|
+
let response;
|
|
825
|
+
try {
|
|
826
|
+
response = await this.settings.model.complete({ system, messages: this.window(system, specs), tools: specs, signal: abort.signal });
|
|
827
|
+
}
|
|
828
|
+
catch (error) {
|
|
829
|
+
if (!abort.signal.aborted)
|
|
830
|
+
throw error;
|
|
831
|
+
}
|
|
832
|
+
finally {
|
|
833
|
+
this.stepAbort = null;
|
|
834
|
+
}
|
|
835
|
+
if (abort.signal.aborted || !response) {
|
|
836
|
+
restarts++;
|
|
837
|
+
step--;
|
|
838
|
+
this.settings.emit(this.id, "model.restarted", { ms: Date.now() - callStarted });
|
|
839
|
+
this.absorb();
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
this.settings.emit(this.id, "model.call", {
|
|
843
|
+
ms: Date.now() - callStarted,
|
|
844
|
+
toolCalls: response.toolCalls.map((c) => c.name),
|
|
845
|
+
usage: response.usage,
|
|
846
|
+
...(response.model ? { model: response.model } : {}),
|
|
847
|
+
});
|
|
848
|
+
if (response.text)
|
|
849
|
+
this.settings.emit(this.id, "text.hidden", { text: response.text });
|
|
850
|
+
const talk = response.toolCalls.filter((c) => this.isTalk(c.name));
|
|
851
|
+
const work = response.toolCalls.filter((c) => !this.isTalk(c.name));
|
|
852
|
+
const first = this.firstResponse;
|
|
853
|
+
this.firstResponse = false;
|
|
854
|
+
const reached = new Set(talk.flatMap((c) => this.targets(c)));
|
|
855
|
+
const waiting = [...this.unanswered].filter((c) => !reached.has(c));
|
|
856
|
+
if (first && waiting.length && work.length) {
|
|
857
|
+
// Acknowledgement first: these calls do not run until the person has heard from the agent.
|
|
858
|
+
this.settings.emit(this.id, "ack.required", { held: work.map((c) => c.name), channels: waiting });
|
|
859
|
+
this.history.push({
|
|
860
|
+
role: "user",
|
|
861
|
+
content: several
|
|
862
|
+
? `[Chinwag] They have not heard from you on ${waiting.join(" or ")} yet. In one response, acknowledge them there (react to their message or send a short line) and make your tool calls again.`
|
|
863
|
+
: ACKNOWLEDGE_FIRST,
|
|
864
|
+
});
|
|
865
|
+
this.flush();
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
if (!response.toolCalls.length) {
|
|
869
|
+
this.history.push({ role: "assistant", content: response.text });
|
|
870
|
+
const owes = [...new Set([...this.unanswered, ...this.owed.filter((o) => o.mode === "message").map((o) => o.channel)])];
|
|
871
|
+
if (owes.length && !reminded) {
|
|
872
|
+
reminded = true;
|
|
873
|
+
this.history.push({
|
|
874
|
+
role: "user",
|
|
875
|
+
content: several
|
|
876
|
+
? `[Chinwag] INTERNAL MESSAGE, not from them: ${response.text ? "your text above never reached them" : "they have not heard from you"} on ${owes.join(" or ")}. Reply with ${owes.map((c) => sendToolName(c)).join(" or ")}, without mentioning this note${response.text ? ", that text" : ""} or any delay.`
|
|
877
|
+
: `[Chinwag] INTERNAL MESSAGE, not from them: ${response.text ? "your text above never reached them" : "they have not heard from you"}. Reply with ${sendToolName(owes[0])}, without mentioning this note${response.text ? ", that text" : ""} or any delay.`,
|
|
878
|
+
});
|
|
879
|
+
this.flush();
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
// After a returned result, only new plain text is sent back: silence may be the answer, and
|
|
883
|
+
// text that repeats a message already sent has been said.
|
|
884
|
+
const said = !!response.text && !this.sentThisTurn.has(response.text.trim());
|
|
885
|
+
const owedResult = this.resultUnsent === "returned" && said;
|
|
886
|
+
if ((this.resultUnsent === "background" || owedResult) && !this.tasks.size && !reminded) {
|
|
887
|
+
// A result came back and the agent said nothing to them: it may have written the answer as plain text.
|
|
888
|
+
reminded = true;
|
|
889
|
+
this.history.push({ role: "user", content: `[Chinwag] INTERNAL MESSAGE, not from them: they have not heard from you since the result above, and text outside ${this.sendToolsNow()} never reaches them. If the result is for them, tell them with ${this.sendToolsNow()}, without mentioning this note, any earlier text or any delay.` });
|
|
890
|
+
this.flush();
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
this.flush();
|
|
894
|
+
if (this.absorb())
|
|
895
|
+
continue;
|
|
896
|
+
break;
|
|
897
|
+
}
|
|
898
|
+
this.history.push({ role: "assistant", content: response.text, toolCalls: response.toolCalls });
|
|
899
|
+
this.flush();
|
|
900
|
+
if (work.length)
|
|
901
|
+
acted = true;
|
|
902
|
+
const working = this.runTools(work);
|
|
903
|
+
const said = [];
|
|
904
|
+
for (const call of talk)
|
|
905
|
+
said.push(await this.talk(call));
|
|
906
|
+
const { results: done, finished } = await working;
|
|
907
|
+
// A result the agent has not passed on yet: if it ends the turn without a message, it is reminded once.
|
|
908
|
+
if (finished)
|
|
909
|
+
this.resultUnsent = "returned";
|
|
910
|
+
const problems = said.some((s) => s.refused);
|
|
911
|
+
const results = new Map();
|
|
912
|
+
talk.forEach((call, i) => results.set(call.id, said[i].content));
|
|
913
|
+
work.forEach((call, i) => results.set(call.id, this.capped(done[i])));
|
|
914
|
+
for (const call of response.toolCalls) {
|
|
915
|
+
this.history.push({ role: "tool", toolCallId: call.id, content: results.get(call.id) ?? "" });
|
|
916
|
+
}
|
|
917
|
+
this.flush();
|
|
918
|
+
// Only messages: the turn is over unless one was refused or something new came in meanwhile, or
|
|
919
|
+
// this replies to their message without having used any tool: it gets one more step, in case it
|
|
920
|
+
// meant to act next (it said "getting your order" and has not asked for it yet). A tool
|
|
921
|
+
// used, or still running in the background, means it already acted.
|
|
922
|
+
if (!work.length && !problems && !this.absorb()) {
|
|
923
|
+
if (!theirs || followedUp || acted || this.tasks.size)
|
|
924
|
+
break;
|
|
925
|
+
followedUp = true;
|
|
926
|
+
}
|
|
927
|
+
this.absorb();
|
|
928
|
+
}
|
|
929
|
+
for (const o of this.owed.splice(0)) {
|
|
930
|
+
const link = o.windowClosed && o.nudge ? this.channels.get(o.channel) : undefined;
|
|
931
|
+
if (link) {
|
|
932
|
+
const { nudge } = o;
|
|
933
|
+
this.persist(() => this.settings.windowClosed(nudge, { channel: o.channel, address: link.address }));
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
if (o.mode === "message")
|
|
937
|
+
this.settle(o, "failed", "the agent sent nothing");
|
|
938
|
+
else
|
|
939
|
+
this.settle(o, o.mode === "check" ? "quiet" : "silent");
|
|
940
|
+
}
|
|
941
|
+
this.resultUnsent = false;
|
|
942
|
+
this.persist(() => store.updateConversation(this.id, { turnOpen: false }));
|
|
943
|
+
this.settings.emit(this.id, "turn.end", { ms: Date.now() - started });
|
|
944
|
+
}
|
|
945
|
+
isTalk(name) {
|
|
946
|
+
return this.sendTools.has(name) || name === REACT || name === UPDATE;
|
|
947
|
+
}
|
|
948
|
+
/** The channels a talk call reaches (for the acknowledgement rule), before it runs. */
|
|
949
|
+
targets(call) {
|
|
950
|
+
if (this.sendTools.has(call.name))
|
|
951
|
+
return [this.sendTools.get(call.name)];
|
|
952
|
+
if (call.name === UPDATE)
|
|
953
|
+
return [...this.channels.values()].filter((c) => c.info.status).map((c) => c.name);
|
|
954
|
+
try {
|
|
955
|
+
const channel = this.messageIds.get(JSON.parse(call.arguments || "{}").message_id);
|
|
956
|
+
return channel ? [channel] : [];
|
|
957
|
+
}
|
|
958
|
+
catch {
|
|
959
|
+
return [];
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Run one of the send tools, react or share_updates. A message that went out says where it was
|
|
964
|
+
* delivered; one that failed on the way is told as a message of its own. `refused` is a call
|
|
965
|
+
* that did nothing because of its input: the agent gets another step to fix it.
|
|
966
|
+
*/
|
|
967
|
+
async talk(call) {
|
|
968
|
+
const refused = (content) => ({ content, refused: true });
|
|
969
|
+
const done = (content = "") => ({ content, refused: false });
|
|
970
|
+
let raw;
|
|
971
|
+
try {
|
|
972
|
+
raw = JSON.parse(call.arguments || "{}");
|
|
973
|
+
}
|
|
974
|
+
catch {
|
|
975
|
+
return refused("The arguments were not valid JSON.");
|
|
976
|
+
}
|
|
977
|
+
const channel = this.sendTools.get(call.name);
|
|
978
|
+
if (channel) {
|
|
979
|
+
const link = this.channels.get(channel);
|
|
980
|
+
const several = this.sendTools.size > 1;
|
|
981
|
+
// Some models fill every optional field, with "" or null when they have nothing: that is not sent.
|
|
982
|
+
const given = raw && typeof raw === "object" ? raw : {};
|
|
983
|
+
for (const k of Object.keys(link.params ?? {}))
|
|
984
|
+
if (given[k] === "" || given[k] === null)
|
|
985
|
+
delete given[k];
|
|
986
|
+
const parsed = sendInput(link.info, several, link.params).safeParse(raw);
|
|
987
|
+
if (!parsed.success)
|
|
988
|
+
return refused(`Not sent, fix and send again: ${parsed.error.message}`);
|
|
989
|
+
const input = parsed.data;
|
|
990
|
+
const fields = Object.fromEntries(Object.keys(link.params ?? {}).flatMap((k) => (input[k] === undefined ? [] : [[k, input[k]]])));
|
|
991
|
+
const hasFields = Object.keys(fields).length > 0;
|
|
992
|
+
if (several && !input.confirm && !this.recentChannels.includes(channel)) {
|
|
993
|
+
const last = link.lastMessageAt ? ` (last time ${ago(this.settings.now() - link.lastMessageAt)} ago)` : "";
|
|
994
|
+
return refused(`Not sent: they have not written on ${channel} in their last 5 messages${last}. If you are sure, call ${call.name} again with confirm: true.`);
|
|
995
|
+
}
|
|
996
|
+
if (input.reply_to && this.messageIds.get(input.reply_to) !== channel)
|
|
997
|
+
return refused(`Not sent: there is no message ${input.reply_to} on ${channel} to reply to.`);
|
|
998
|
+
const refusal = sendRefusal(link.info, input);
|
|
999
|
+
if (refusal)
|
|
1000
|
+
return refused(refusal);
|
|
1001
|
+
const outbound = fitMessage(link.info, {
|
|
1002
|
+
type: "message",
|
|
1003
|
+
text: input.text,
|
|
1004
|
+
...(input.reply_to ? { replyTo: input.reply_to } : {}),
|
|
1005
|
+
...(input.buttons?.length ? { buttons: input.buttons } : {}),
|
|
1006
|
+
...(input.list ? { list: input.list } : {}),
|
|
1007
|
+
...(input.link ? { link: input.link } : {}),
|
|
1008
|
+
...(input.image ? { image: input.image } : {}),
|
|
1009
|
+
...(hasFields ? { fields } : {}),
|
|
1010
|
+
});
|
|
1011
|
+
const failure = await this.send(channel, outbound);
|
|
1012
|
+
if (failure) {
|
|
1013
|
+
this.undelivered(input.text, failure);
|
|
1014
|
+
return done();
|
|
1015
|
+
}
|
|
1016
|
+
this.settings.emit(this.id, "message.out", {
|
|
1017
|
+
text: outbound.text,
|
|
1018
|
+
channel,
|
|
1019
|
+
buttons: input.buttons,
|
|
1020
|
+
list: input.list,
|
|
1021
|
+
link: outbound.link,
|
|
1022
|
+
image: outbound.image,
|
|
1023
|
+
replyTo: input.reply_to,
|
|
1024
|
+
...(hasFields ? { fields } : {}),
|
|
1025
|
+
});
|
|
1026
|
+
this.sent();
|
|
1027
|
+
this.sentThisTurn.add(input.text.trim());
|
|
1028
|
+
await this.acknowledged(channel);
|
|
1029
|
+
return done(JSON.stringify({ delivered: true, channel }));
|
|
1030
|
+
}
|
|
1031
|
+
if (call.name === REACT) {
|
|
1032
|
+
const input = reactInput.safeParse(raw);
|
|
1033
|
+
if (!input.success)
|
|
1034
|
+
return refused(`Invalid input: ${input.error.message}`);
|
|
1035
|
+
const on = this.messageIds.get(input.data.message_id);
|
|
1036
|
+
if (!on)
|
|
1037
|
+
return refused(`There is no message ${input.data.message_id}.`);
|
|
1038
|
+
if (!this.channels.get(on)?.info.reactions)
|
|
1039
|
+
return refused(`Reactions do not work on ${on}; send a short line instead.`);
|
|
1040
|
+
const failure = await this.send(on, { type: "reaction", emoji: input.data.emoji, messageId: input.data.message_id });
|
|
1041
|
+
if (failure)
|
|
1042
|
+
return refused(`The reaction did not go through: ${failure}`);
|
|
1043
|
+
this.settings.emit(this.id, "reaction", { emoji: input.data.emoji, messageId: input.data.message_id, channel: on });
|
|
1044
|
+
await this.acknowledged(on);
|
|
1045
|
+
return done();
|
|
1046
|
+
}
|
|
1047
|
+
if (call.name === UPDATE) {
|
|
1048
|
+
const input = updateInput.safeParse(raw);
|
|
1049
|
+
if (!input.success)
|
|
1050
|
+
return refused(`Invalid input: ${input.error.message}`);
|
|
1051
|
+
for (const link of this.channels.values()) {
|
|
1052
|
+
if (!link.info.status)
|
|
1053
|
+
continue;
|
|
1054
|
+
if (await this.send(link.name, { type: "status", text: input.data.text }))
|
|
1055
|
+
continue;
|
|
1056
|
+
this.settings.emit(this.id, "status", { text: input.data.text, channel: link.name });
|
|
1057
|
+
await this.acknowledged(link.name);
|
|
1058
|
+
}
|
|
1059
|
+
return done();
|
|
1060
|
+
}
|
|
1061
|
+
return refused(`There is no tool named ${call.name}.`);
|
|
1062
|
+
}
|
|
1063
|
+
/** How a scheduled run or a nudge ended, stored in order with everything else. */
|
|
1064
|
+
settle(target, status, reason) {
|
|
1065
|
+
this.persist(() => this.settings.settle({ run: target.run, nudge: target.nudge }, status, reason));
|
|
1066
|
+
}
|
|
1067
|
+
/** A message reached the person: whatever this turn owed them is sent. */
|
|
1068
|
+
sent() {
|
|
1069
|
+
this.resultUnsent = false;
|
|
1070
|
+
for (const o of this.owed.splice(0))
|
|
1071
|
+
this.settle(o, "sent");
|
|
1072
|
+
}
|
|
1073
|
+
async acknowledged(channel) {
|
|
1074
|
+
this.unanswered.delete(channel);
|
|
1075
|
+
await this.typing(false, channel);
|
|
1076
|
+
}
|
|
1077
|
+
async typing(on, channel) {
|
|
1078
|
+
if (on === this.typingOn.has(channel))
|
|
1079
|
+
return;
|
|
1080
|
+
if (on)
|
|
1081
|
+
this.typingOn.add(channel);
|
|
1082
|
+
else
|
|
1083
|
+
this.typingOn.delete(channel);
|
|
1084
|
+
await this.send(channel, { type: "typing", on });
|
|
1085
|
+
}
|
|
1086
|
+
/** Hand one outbound to a channel; returns why it failed, or null. */
|
|
1087
|
+
async send(channel, outbound) {
|
|
1088
|
+
if (this.closed)
|
|
1089
|
+
return "this person was forgotten";
|
|
1090
|
+
const link = this.channels.get(channel);
|
|
1091
|
+
if (!link)
|
|
1092
|
+
return `no way to reach them on ${channel} right now`;
|
|
1093
|
+
try {
|
|
1094
|
+
await link.deliver(outbound);
|
|
1095
|
+
return null;
|
|
1096
|
+
}
|
|
1097
|
+
catch (error) {
|
|
1098
|
+
return error instanceof Error ? error.message : String(error);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
/** A fixed-text scheduled message or a text or template nudge, sent with no model turn. */
|
|
1102
|
+
async sendDirect(d) {
|
|
1103
|
+
const settle = (status, reason) => {
|
|
1104
|
+
this.settle(d, status, reason);
|
|
1105
|
+
this.flush([d]);
|
|
1106
|
+
};
|
|
1107
|
+
const link = this.channels.get(d.channel);
|
|
1108
|
+
if (!link)
|
|
1109
|
+
return settle("failed", `no way to reach them on ${d.channel} right now`);
|
|
1110
|
+
if (!d.template && !d.theyWrote && windowClosed(link, this.settings.now()))
|
|
1111
|
+
return settle("refused", "outside_window");
|
|
1112
|
+
// Every direct send is one the business starts (a nudge, or its scheduled text): the person's rules apply.
|
|
1113
|
+
const rules = await this.settings.store.noContact(this.id, this.settings.now());
|
|
1114
|
+
let covered = rules.find((r) => r.hard) ?? null;
|
|
1115
|
+
if (!covered && rules.length) {
|
|
1116
|
+
const started = Date.now();
|
|
1117
|
+
try {
|
|
1118
|
+
const check = await coveredBy(this.settings.model, rules, d.text ?? `the template "${d.template?.name}"`);
|
|
1119
|
+
this.settings.emit(this.id, "model.call", { ms: Date.now() - started, toolCalls: ["decide"], usage: check.usage, purpose: "no-contact check", ...(check.model ? { model: check.model } : {}) });
|
|
1120
|
+
covered = check.rule;
|
|
1121
|
+
}
|
|
1122
|
+
catch (error) {
|
|
1123
|
+
// Not knowing whether a rule covers it, nothing is sent, and the person hears nothing of it.
|
|
1124
|
+
this.settings.emit(this.id, "model.error", { error: String(error), purpose: "no-contact check" });
|
|
1125
|
+
return settle("failed", `their no-contact rules could not be checked: ${errorText(error)}`);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
if (covered)
|
|
1129
|
+
return settle("dropped", `no-contact rule ${covered.id}: ${covered.rule}`);
|
|
1130
|
+
const outbound = d.template ? { type: "template", ...d.template } : { type: "message", text: d.text ?? "" };
|
|
1131
|
+
const failure = await this.send(d.channel, outbound);
|
|
1132
|
+
if (failure)
|
|
1133
|
+
return settle("failed", failure);
|
|
1134
|
+
if (d.template) {
|
|
1135
|
+
// A declared template's text, so the agent knows what the person read (and tapped a reply to).
|
|
1136
|
+
const declared = declaredTemplate(this.settings.templates, d.template);
|
|
1137
|
+
let reads = "";
|
|
1138
|
+
try {
|
|
1139
|
+
if (declared)
|
|
1140
|
+
reads = ` It reads:\n${fillTemplate(declared, d.template).body}`;
|
|
1141
|
+
}
|
|
1142
|
+
catch { }
|
|
1143
|
+
this.history.push({ role: "user", content: `[Chinwag] The business sent them its template "${d.template.name}" on ${d.channel}.${reads}` });
|
|
1144
|
+
}
|
|
1145
|
+
else {
|
|
1146
|
+
const callId = `direct_${d.inbox ?? Date.now()}`;
|
|
1147
|
+
this.history.push({ role: "user", content: `[Chinwag] The business sent them this message through you on ${d.channel}, as written:` });
|
|
1148
|
+
this.history.push({ role: "assistant", content: null, toolCalls: [{ id: callId, name: sendToolName(d.channel), arguments: JSON.stringify({ text: d.text }) }] });
|
|
1149
|
+
this.history.push({ role: "tool", toolCallId: callId, content: JSON.stringify({ delivered: true, channel: d.channel }) });
|
|
1150
|
+
}
|
|
1151
|
+
this.settings.emit(this.id, "message.out", { text: d.text ?? d.template?.name, channel: d.channel, direct: true });
|
|
1152
|
+
settle("sent");
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* The history the model is sent: all of it while it fits the budget, else the latest entries
|
|
1156
|
+
* that do, from the start of one (never between a call and its result), after a line that says
|
|
1157
|
+
* older ones are left out. The latest turn always goes, however long.
|
|
1158
|
+
*/
|
|
1159
|
+
window(system, specs) {
|
|
1160
|
+
const all = this.history.map(forModel);
|
|
1161
|
+
let room = this.settings.historyTokens - tokens(system) - tokens(JSON.stringify(specs)) - tokens(EARLIER);
|
|
1162
|
+
let start = all.length;
|
|
1163
|
+
for (let i = all.length - 1; i >= 0; i--) {
|
|
1164
|
+
room -= size(all[i]);
|
|
1165
|
+
if (room < 0)
|
|
1166
|
+
break;
|
|
1167
|
+
if (all[i].role === "user")
|
|
1168
|
+
start = i;
|
|
1169
|
+
}
|
|
1170
|
+
if (room >= 0)
|
|
1171
|
+
start = 0;
|
|
1172
|
+
else if (start === all.length)
|
|
1173
|
+
start = Math.max(0, all.findLastIndex((m) => m.role === "user"));
|
|
1174
|
+
if (start !== this.leftOut) {
|
|
1175
|
+
this.leftOut = start;
|
|
1176
|
+
if (start)
|
|
1177
|
+
this.settings.emit(this.id, "history.trimmed", { leftOut: start, kept: all.length - start });
|
|
1178
|
+
}
|
|
1179
|
+
return start ? [{ role: "user", content: EARLIER }, ...all.slice(start)] : all;
|
|
1180
|
+
}
|
|
1181
|
+
/** A tool result as the history keeps it: cut at toolResultChars, saying so. */
|
|
1182
|
+
capped(text) {
|
|
1183
|
+
const max = this.settings.toolResultChars;
|
|
1184
|
+
if (text.length <= max)
|
|
1185
|
+
return text;
|
|
1186
|
+
return `${text.slice(0, max)}\n[Chinwag] Cut: the result was ${text.length} characters; only the first ${max} are shown.`;
|
|
1187
|
+
}
|
|
1188
|
+
/** The send tools the agent has right now, for Flow Framework's notes: "send_sms", or "send_sms or send_app". */
|
|
1189
|
+
sendToolsNow() {
|
|
1190
|
+
return [...this.channels.keys()].map((c) => sendToolName(c)).join(" or ");
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Run a response's tool calls together and wait for all of them, except: a message from the
|
|
1194
|
+
* person ends the wait, a finished result waits at most partialAfterMs for the others, and a tool
|
|
1195
|
+
* past its limit stops holding the reply. Whatever is still running then moves to the background.
|
|
1196
|
+
*/
|
|
1197
|
+
async runTools(calls) {
|
|
1198
|
+
const results = [];
|
|
1199
|
+
let finished = 0;
|
|
1200
|
+
const live = [];
|
|
1201
|
+
calls.forEach((call, i) => {
|
|
1202
|
+
const started = this.start(call);
|
|
1203
|
+
if (typeof started === "string")
|
|
1204
|
+
results[i] = started;
|
|
1205
|
+
else
|
|
1206
|
+
live.push({ i, r: started });
|
|
1207
|
+
});
|
|
1208
|
+
let firstDoneAt = null;
|
|
1209
|
+
for (;;) {
|
|
1210
|
+
for (const { i, r } of live) {
|
|
1211
|
+
if (!r.result || results[i] !== undefined)
|
|
1212
|
+
continue;
|
|
1213
|
+
results[i] = r.result.text;
|
|
1214
|
+
finished++;
|
|
1215
|
+
firstDoneAt ??= Date.now();
|
|
1216
|
+
this.settings.emit(this.id, "tool.end", { callId: r.call.id, tool: r.call.name, ms: Date.now() - r.startedAt, ok: r.result.ok, inline: true, output: clipOutput(r.result.text) });
|
|
1217
|
+
}
|
|
1218
|
+
const open = live.filter(({ i }) => results[i] === undefined);
|
|
1219
|
+
if (!open.length)
|
|
1220
|
+
break;
|
|
1221
|
+
if (this.closed) {
|
|
1222
|
+
for (const { i } of open)
|
|
1223
|
+
results[i] = "";
|
|
1224
|
+
break;
|
|
1225
|
+
}
|
|
1226
|
+
const now = Date.now();
|
|
1227
|
+
const all = this.unread() ? "message" : firstDoneAt !== null && now - firstDoneAt >= this.settings.partialAfterMs ? "partial" : null;
|
|
1228
|
+
if (all) {
|
|
1229
|
+
for (const { i, r } of open)
|
|
1230
|
+
results[i] = this.detach(r, all);
|
|
1231
|
+
break;
|
|
1232
|
+
}
|
|
1233
|
+
const late = open.filter(({ r }) => now - r.startedAt >= r.limitMs);
|
|
1234
|
+
for (const { i, r } of late)
|
|
1235
|
+
results[i] = this.detach(r, "limit");
|
|
1236
|
+
if (late.length)
|
|
1237
|
+
continue;
|
|
1238
|
+
const wakeAt = Math.min(...open.map(({ r }) => r.startedAt + r.limitMs), firstDoneAt === null ? Infinity : firstDoneAt + this.settings.partialAfterMs);
|
|
1239
|
+
await new Promise((resolve) => {
|
|
1240
|
+
const timer = setTimeout(wake, Math.max(0, wakeAt - Date.now()));
|
|
1241
|
+
function wake() {
|
|
1242
|
+
clearTimeout(timer);
|
|
1243
|
+
resolve();
|
|
1244
|
+
}
|
|
1245
|
+
this.wakeTools = wake;
|
|
1246
|
+
for (const { r } of open)
|
|
1247
|
+
void r.outcome.then(wake);
|
|
1248
|
+
});
|
|
1249
|
+
this.wakeTools = null;
|
|
1250
|
+
}
|
|
1251
|
+
return { results: results, finished };
|
|
1252
|
+
}
|
|
1253
|
+
/** Start one tool call; the model's input is checked first (a refusal is the call's result). */
|
|
1254
|
+
start(call) {
|
|
1255
|
+
const tool = this.builtins.get(call.name) ?? this.settings.tools.get(call.name);
|
|
1256
|
+
if (!tool)
|
|
1257
|
+
return `There is no tool named ${call.name}.`;
|
|
1258
|
+
let raw;
|
|
1259
|
+
try {
|
|
1260
|
+
raw = JSON.parse(call.arguments || "{}");
|
|
1261
|
+
}
|
|
1262
|
+
catch {
|
|
1263
|
+
return "The arguments were not valid JSON.";
|
|
1264
|
+
}
|
|
1265
|
+
const parsed = tool.input.safeParse(raw);
|
|
1266
|
+
if (!parsed.success)
|
|
1267
|
+
return `Invalid input: ${parsed.error.message}`;
|
|
1268
|
+
// The same call while it is still running in the background is not run again: its result is coming.
|
|
1269
|
+
const input = stableJson(parsed.data);
|
|
1270
|
+
const running = [...this.tasks.values()].find((t) => t.tool === call.name && t.input === input);
|
|
1271
|
+
if (running) {
|
|
1272
|
+
this.settings.emit(this.id, "tool.repeated", { callId: call.id, tool: call.name, task: running.id });
|
|
1273
|
+
return `[Chinwag] Not run: ${call.name} with this input is already running in the background (${running.id}). Its result will come to you here; don't call it again.`;
|
|
1274
|
+
}
|
|
1275
|
+
this.settings.emit(this.id, "tool.start", { callId: call.id, tool: call.name, input: parsed.data });
|
|
1276
|
+
const key = createHash("sha256").update(`${this.id}\u0000${this.turns}\u0000${call.name}\u0000${input}`).digest("hex").slice(0, 32);
|
|
1277
|
+
const context = { conversationId: this.id, person: this.personKey, key: this.personKey.startsWith("key:") ? this.personKey.slice(4) : null, channel: this.currentChannel(), callId: call.id, idempotencyKey: key, signal: this.lifetime.signal };
|
|
1278
|
+
const r = {
|
|
1279
|
+
call,
|
|
1280
|
+
input,
|
|
1281
|
+
startedAt: Date.now(),
|
|
1282
|
+
limitMs: tool.deadlineMs ?? this.settings.deadlineMs,
|
|
1283
|
+
outcome: Promise.resolve()
|
|
1284
|
+
.then(() => tool.run(parsed.data, context))
|
|
1285
|
+
.then((output) => ({ ok: true, text: render(output) }), (error) => ({ ok: false, text: `Failed: ${error instanceof Error ? error.message : String(error)}` })),
|
|
1286
|
+
};
|
|
1287
|
+
void r.outcome.then((result) => (r.result = result));
|
|
1288
|
+
return r;
|
|
1289
|
+
}
|
|
1290
|
+
/** The reply stops waiting for this tool: it carries on in the background and its result comes back as a note. */
|
|
1291
|
+
detach(r, why) {
|
|
1292
|
+
const { call, startedAt } = r;
|
|
1293
|
+
const task = { id: `task_${++this.taskCount}`, tool: call.name, input: r.input, startedAt };
|
|
1294
|
+
this.tasks.set(task.id, task);
|
|
1295
|
+
this.persist(() => this.settings.store.addTask(this.id, task.id, call.id, call.name, startedAt));
|
|
1296
|
+
this.settings.emit(this.id, "tool.detached", { callId: call.id, tool: call.name, task: task.id, ms: Date.now() - startedAt, why });
|
|
1297
|
+
void r.outcome.then((result) => {
|
|
1298
|
+
this.tasks.delete(task.id);
|
|
1299
|
+
this.persist(() => this.settings.store.removeTask(this.id, task.id));
|
|
1300
|
+
this.settings.emit(this.id, "task.done", { callId: call.id, tool: call.name, task: task.id, ms: Date.now() - startedAt, ok: result.ok, output: clipOutput(result.text) });
|
|
1301
|
+
this.note(`[Background result: ${call.name} (${task.id})] ${this.capped(result.text)}\n[Chinwag] They have not seen this result: tell them with ${this.sendToolsNow()}.`);
|
|
1302
|
+
this.checkIdle();
|
|
1303
|
+
});
|
|
1304
|
+
return (`[Chinwag] ${call.name} started and is still running in the background (${task.id}). Do not call ${call.name} again: ` +
|
|
1305
|
+
`its result will come to you here when it finishes. Until then don't tell them it is done; when it arrives, tell them with ${this.sendToolsNow()}.` +
|
|
1306
|
+
(why === "partial" ? " The other results are in: tell them this now only if it answers what they asked; the rest is coming." : ""));
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
/** The business may not message first on this channel now (WhatsApp, past 24 hours). */
|
|
1310
|
+
export function windowClosed(link, now) {
|
|
1311
|
+
const hours = link.info.replyWindowHours;
|
|
1312
|
+
if (!hours)
|
|
1313
|
+
return false;
|
|
1314
|
+
return link.lastMessageAt === null || now - link.lastMessageAt > hours * 3_600_000;
|
|
1315
|
+
}
|
|
1316
|
+
const SEND_KEYS = new Set(["text", "reply_to", "buttons", "list", "link", "image", "confirm"]);
|
|
1317
|
+
/**
|
|
1318
|
+
* The history as the person saw it: their messages (with their ids and channels), what the agent
|
|
1319
|
+
* delivered with its send tools and reactions, and its other tool calls with their results.
|
|
1320
|
+
* Flow Framework's notes and the agent's plain text never reached the person and are left out. `only` is
|
|
1321
|
+
* the channel of untagged messages (an agent with one channel does not tag them).
|
|
1322
|
+
*/
|
|
1323
|
+
export function transcriptOf(history, only) {
|
|
1324
|
+
const lines = [];
|
|
1325
|
+
const results = new Map();
|
|
1326
|
+
for (const { entry, at } of history)
|
|
1327
|
+
if (entry.role === "tool")
|
|
1328
|
+
results.set(entry.toolCallId, { content: entry.content, at });
|
|
1329
|
+
const channelOf = new Map();
|
|
1330
|
+
for (const { entry, at } of history) {
|
|
1331
|
+
if (entry.role === "user") {
|
|
1332
|
+
const template = /^\[Chinwag\] The business sent them its template "(.*)" on ([\w-]+)\.(?: It reads:\n([\s\S]*))?$/.exec(entry.content);
|
|
1333
|
+
if (template) {
|
|
1334
|
+
lines.push({ kind: "out", channel: template[2], text: template[3] ?? "", at, template: template[1] });
|
|
1335
|
+
continue;
|
|
1336
|
+
}
|
|
1337
|
+
for (const m of personMessages(entry, only)) {
|
|
1338
|
+
channelOf.set(m.id, m.channel);
|
|
1339
|
+
lines.push({ kind: "in", channel: m.channel, id: m.id, text: m.text, at, ...(m.choice ? { choice: true } : {}) });
|
|
1340
|
+
}
|
|
1341
|
+
continue;
|
|
1342
|
+
}
|
|
1343
|
+
if (entry.role !== "assistant")
|
|
1344
|
+
continue;
|
|
1345
|
+
for (const call of entry.toolCalls ?? []) {
|
|
1346
|
+
const result = results.get(call.id);
|
|
1347
|
+
let args = {};
|
|
1348
|
+
try {
|
|
1349
|
+
args = JSON.parse(call.arguments || "{}");
|
|
1350
|
+
}
|
|
1351
|
+
catch { }
|
|
1352
|
+
if (call.name.startsWith("send_")) {
|
|
1353
|
+
let delivered = {};
|
|
1354
|
+
try {
|
|
1355
|
+
delivered = JSON.parse(result?.content ?? "");
|
|
1356
|
+
}
|
|
1357
|
+
catch { }
|
|
1358
|
+
if (!delivered.delivered || !delivered.channel)
|
|
1359
|
+
continue;
|
|
1360
|
+
const fields = Object.fromEntries(Object.entries(args).filter(([k, v]) => !SEND_KEYS.has(k) && v !== undefined && v !== null && v !== ""));
|
|
1361
|
+
lines.push({
|
|
1362
|
+
kind: "out",
|
|
1363
|
+
channel: delivered.channel,
|
|
1364
|
+
text: String(args.text ?? ""),
|
|
1365
|
+
at: result.at,
|
|
1366
|
+
...(typeof args.reply_to === "string" ? { replyTo: args.reply_to } : {}),
|
|
1367
|
+
...(Array.isArray(args.buttons) && args.buttons.length ? { buttons: args.buttons } : {}),
|
|
1368
|
+
...(args.list ? { list: args.list } : {}),
|
|
1369
|
+
...(args.link ? { link: args.link } : {}),
|
|
1370
|
+
...(args.image ? { image: args.image } : {}),
|
|
1371
|
+
...(Object.keys(fields).length ? { fields } : {}),
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
else if (call.name === REACT) {
|
|
1375
|
+
const channel = channelOf.get(String(args.message_id));
|
|
1376
|
+
if (result?.content === "" && channel)
|
|
1377
|
+
lines.push({ kind: "reaction", channel, messageId: String(args.message_id), emoji: String(args.emoji), at: result.at });
|
|
1378
|
+
}
|
|
1379
|
+
else if (call.name !== UPDATE && result) {
|
|
1380
|
+
lines.push({ kind: "tool", callId: call.id, tool: call.name, input: args, output: result.content, ok: !result.content.startsWith("Failed:"), at: result.at });
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
return lines;
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* How a message from the person reads to the agent, after its id: "[m5] text", with any quote or
|
|
1388
|
+
* tap, and its files marked before the text: "[m5] [photo] is this ripe?".
|
|
1389
|
+
*/
|
|
1390
|
+
function describe(incoming, files) {
|
|
1391
|
+
const parts = [];
|
|
1392
|
+
if (incoming.replyTo)
|
|
1393
|
+
parts.push(`, replying to ${incoming.replyTo.fromAgent ? "your" : "their own"} message "${clip(incoming.replyTo.text)}"`);
|
|
1394
|
+
if (incoming.choice)
|
|
1395
|
+
return `${parts.join("")}, tapped] ${incoming.choice.title}`;
|
|
1396
|
+
const body = [files?.marks.join(" "), files?.transcript, incoming.text].filter(Boolean).join(" ");
|
|
1397
|
+
return `${parts.join("")}] ${body}`;
|
|
1398
|
+
}
|
|
1399
|
+
/** What the person wrote, as the transcript shows it: the tap, or the files' marks and the text. */
|
|
1400
|
+
function shownText(incoming, files) {
|
|
1401
|
+
if (incoming.choice)
|
|
1402
|
+
return incoming.choice.title;
|
|
1403
|
+
return [files.marks.join(" "), files.transcript, incoming.text].filter(Boolean).join(" ");
|
|
1404
|
+
}
|
|
1405
|
+
/**
|
|
1406
|
+
* The person's messages in a history entry: kept beside it, or (in entries stored before they
|
|
1407
|
+
* were) read back from the text, where only Flow Framework's own m1, m2, ... ids can be told apart.
|
|
1408
|
+
*/
|
|
1409
|
+
function personMessages(entry, only) {
|
|
1410
|
+
if (entry.messages)
|
|
1411
|
+
return entry.messages;
|
|
1412
|
+
const starts = [...entry.content.matchAll(/^\[(?:([\w-]+) · )?(m\d+)(?=[,\]])/gm)];
|
|
1413
|
+
return starts.map((m, i) => {
|
|
1414
|
+
const rest = entry.content.slice(m.index + m[0].length, i + 1 < starts.length ? starts[i + 1].index - 1 : undefined);
|
|
1415
|
+
const tapped = rest.indexOf(", tapped] ");
|
|
1416
|
+
const text = rest.startsWith("] ") ? rest.slice(2) : tapped >= 0 ? rest.slice(tapped + 10) : rest.slice(rest.indexOf('"] ') + 3);
|
|
1417
|
+
return { id: m[2], channel: m[1] ?? only, text, ...(tapped >= 0 ? { choice: true } : {}) };
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
/** About how many tokens a text is (a little over, for safety: 3 characters a token). */
|
|
1421
|
+
function tokens(text) {
|
|
1422
|
+
return Math.ceil((text?.length ?? 0) / 3);
|
|
1423
|
+
}
|
|
1424
|
+
/** About how many tokens a history entry takes, a photo or PDF shown counted as 1,500. */
|
|
1425
|
+
function size(entry) {
|
|
1426
|
+
if (entry.role === "assistant")
|
|
1427
|
+
return tokens(entry.content) + tokens(JSON.stringify(entry.toolCalls ?? []));
|
|
1428
|
+
if (entry.role === "tool")
|
|
1429
|
+
return tokens(entry.content);
|
|
1430
|
+
return tokens(entry.content) + (entry.media?.filter((m) => m.data).length ?? 0) * 1_500;
|
|
1431
|
+
}
|
|
1432
|
+
/** A photo or PDF as it is stored: named, without its bytes. */
|
|
1433
|
+
function stored(media) {
|
|
1434
|
+
const { data: _bytes, ...rest } = media;
|
|
1435
|
+
return rest;
|
|
1436
|
+
}
|
|
1437
|
+
/** A history entry as the model is sent it: media no longer shown are taken off, and it is told. */
|
|
1438
|
+
function forModel(entry) {
|
|
1439
|
+
if (entry.role !== "user")
|
|
1440
|
+
return entry;
|
|
1441
|
+
if (!entry.media)
|
|
1442
|
+
return { role: "user", content: entry.content };
|
|
1443
|
+
const shown = entry.media.filter((m) => m.data);
|
|
1444
|
+
if (shown.length === entry.media.length)
|
|
1445
|
+
return { role: "user", content: entry.content, media: entry.media };
|
|
1446
|
+
return { role: "user", content: `${entry.content}\n${NOT_SHOWN}`, ...(shown.length ? { media: shown } : {}) };
|
|
1447
|
+
}
|
|
1448
|
+
/** A tool's output as events carry it: whole up to 4,000 characters (for debugging and observability). */
|
|
1449
|
+
function clipOutput(text) {
|
|
1450
|
+
return text.length > 4_000 ? `${text.slice(0, 4_000)}…` : text;
|
|
1451
|
+
}
|
|
1452
|
+
function errorText(error) {
|
|
1453
|
+
return error instanceof Error ? error.message : String(error);
|
|
1454
|
+
}
|
|
1455
|
+
function clip(text) {
|
|
1456
|
+
return text.length > 80 ? `${text.slice(0, 77)}…` : text;
|
|
1457
|
+
}
|
|
1458
|
+
function ago(ms) {
|
|
1459
|
+
const minutes = Math.round(ms / 60_000);
|
|
1460
|
+
if (minutes < 60)
|
|
1461
|
+
return `${minutes} minutes`;
|
|
1462
|
+
const hours = Math.round(minutes / 60);
|
|
1463
|
+
return hours < 48 ? `${hours} hours` : `${Math.round(hours / 24)} days`;
|
|
1464
|
+
}
|
|
1465
|
+
/** JSON with object keys sorted, so the same input always reads the same. */
|
|
1466
|
+
function stableJson(value) {
|
|
1467
|
+
return JSON.stringify(value, (_key, v) => (v && typeof v === "object" && !Array.isArray(v) ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b))) : v));
|
|
1468
|
+
}
|
|
1469
|
+
/**
|
|
1470
|
+
* The time, for every turn: in their zone when the business or the person gave it, else in the
|
|
1471
|
+
* business's, saying theirs is not known (never guessed from the business's).
|
|
1472
|
+
*/
|
|
1473
|
+
export function nowLine(now, personZone, agentZone) {
|
|
1474
|
+
if (personZone)
|
|
1475
|
+
return `[Chinwag] It is now ${formatLocal(personZone, now)}, their time.`;
|
|
1476
|
+
return `[Chinwag] It is now ${formatLocal(agentZone ?? "UTC", now)}, the business's time. Their time zone is not known: ask where they are before anything that depends on their local time.`;
|
|
1477
|
+
}
|
|
1478
|
+
export const PROFILE_TOOL = "get_user_profile";
|
|
1479
|
+
/** The built-in tool over createAgent's `profile`: no input, so it is always about the person in this conversation. */
|
|
1480
|
+
export function profileTool(profile) {
|
|
1481
|
+
return tool({
|
|
1482
|
+
name: PROFILE_TOOL,
|
|
1483
|
+
description: "This person's profile from the business: who they are and what the business knows about them. Call it when you need it; it is always about the person you are talking to.",
|
|
1484
|
+
input: z.object({}),
|
|
1485
|
+
run: (_input, context) => profile({ conversationId: context.conversationId, person: context.person, channel: context.channel }),
|
|
1486
|
+
});
|
|
1487
|
+
}
|