@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,303 @@
|
|
|
1
|
+
// Traces of every turn, for a traces endpoint (a dashboard that shows and grades conversations).
|
|
2
|
+
// An event sink like any other: it folds a conversation's events into one trace per turn (a root
|
|
3
|
+
// span, a span per model call, per tool and per message sent) and posts them as OTLP/JSON to
|
|
4
|
+
// `<url>/v1/traces` with the agent's trace key.
|
|
5
|
+
//
|
|
6
|
+
// It never slows or breaks a conversation: a finished turn is queued (bounded; the oldest goes when
|
|
7
|
+
// it is full) and sent in the background, every turn finished meanwhile in the same request; a
|
|
8
|
+
// failed request is dropped, logged once per kind of failure, and never names the key.
|
|
9
|
+
//
|
|
10
|
+
// Content (what the person and the agent wrote, tool inputs and results) is sent only with
|
|
11
|
+
// `content: true` (FLOW_FRAMEWORK_TRACES_CONTENT=1); without it the spans hold ids, names, timing and counts,
|
|
12
|
+
// and the endpoint has no messages to show or grade.
|
|
13
|
+
//
|
|
14
|
+
// Under `flow test`, which plays the agent in this process, every trace says so: the CLI sets
|
|
15
|
+
// FLOW_FRAMEWORK_RUN_KIND=test before it loads the agent, and FLOW_FRAMEWORK_TEST_SESSION once the session opens.
|
|
16
|
+
// The resource's `chinwag.run_kind` is then "test" (else "production") and each turn's root span
|
|
17
|
+
// names the session (`chinwag.test.session`), so the endpoint keeps test traces apart from real
|
|
18
|
+
// conversations and links them to their test run.
|
|
19
|
+
import { randomBytes } from "node:crypto";
|
|
20
|
+
import { setting } from "./names.js";
|
|
21
|
+
/** Finished turns waiting to be sent; past this the oldest is dropped. */
|
|
22
|
+
const MAX_QUEUED = 512;
|
|
23
|
+
/** Turns per request. */
|
|
24
|
+
const BATCH = 64;
|
|
25
|
+
const TIMEOUT_MS = 10_000;
|
|
26
|
+
/** The cap per content attribute. */
|
|
27
|
+
const MAX_TEXT = 32 * 1024;
|
|
28
|
+
/** Where traces go when a key is set and no address is, and the test cloud `flow test` uses when none is named. */
|
|
29
|
+
export const OBSERVE_URL = "https://flow.engineer/observe";
|
|
30
|
+
/** A setting from the environment: FLOW_FRAMEWORK_<name>, else CHINWAG_<name> (src/names.ts). */
|
|
31
|
+
export { setting };
|
|
32
|
+
/**
|
|
33
|
+
* The traces options from the environment: on when FLOW_FRAMEWORK_TRACES_KEY is set, except under
|
|
34
|
+
* `node --test` (NODE_TEST_CONTEXT), so an agent's own tests never send their turns. The address
|
|
35
|
+
* is FLOW_FRAMEWORK_TRACES_URL, else {@link OBSERVE_URL}.
|
|
36
|
+
*/
|
|
37
|
+
export function tracesFromEnv(env = process.env) {
|
|
38
|
+
const key = setting("TRACES_KEY", env)?.trim() ?? "";
|
|
39
|
+
if (!key || env.NODE_TEST_CONTEXT)
|
|
40
|
+
return undefined;
|
|
41
|
+
return { key, url: setting("TRACES_URL", env)?.trim() || OBSERVE_URL, content: setting("TRACES_CONTENT", env) === "1" };
|
|
42
|
+
}
|
|
43
|
+
/** The endpoint's channel names: Flow Framework's custom UIs are web or app chats. */
|
|
44
|
+
const CHANNEL_KIND = { sms: "sms", whatsapp: "whatsapp", custom: "web" };
|
|
45
|
+
let warnedMetadataOnly = false;
|
|
46
|
+
export function traces(options, agent) {
|
|
47
|
+
const base = (options.url ?? "").replace(/\/+$/, "");
|
|
48
|
+
if (!options.key)
|
|
49
|
+
throw new Error("flow: traces need a key");
|
|
50
|
+
if (!base)
|
|
51
|
+
throw new Error("flow: traces need your traces endpoint's URL");
|
|
52
|
+
if (!allowedUrl(base))
|
|
53
|
+
throw new Error("flow: traces go over https (http only to this machine)");
|
|
54
|
+
const endpoint = `${base}/v1/traces`;
|
|
55
|
+
const content = options.content === true;
|
|
56
|
+
if (!content && !warnedMetadataOnly) {
|
|
57
|
+
warnedMetadataOnly = true;
|
|
58
|
+
console.warn("flow: traces are metadata only. Set FLOW_FRAMEWORK_TRACES_CONTENT=1 to send what was written.");
|
|
59
|
+
}
|
|
60
|
+
const open = new Map();
|
|
61
|
+
/** Messages and background results that came while no turn was open: they join the next one. */
|
|
62
|
+
const waiting = new Map();
|
|
63
|
+
const queue = [];
|
|
64
|
+
const warned = new Set();
|
|
65
|
+
let sending = null;
|
|
66
|
+
const testRun = setting("RUN_KIND") === "test";
|
|
67
|
+
const resource = [attr("service.name", agent.service), attr("telemetry.sdk.name", "chinwag"), attr("chinwag.run_kind", testRun ? "test" : "production")];
|
|
68
|
+
if (options.version)
|
|
69
|
+
resource.push(attr("service.version", options.version));
|
|
70
|
+
function warnOnce(kind, message) {
|
|
71
|
+
if (warned.has(kind))
|
|
72
|
+
return;
|
|
73
|
+
warned.add(kind);
|
|
74
|
+
console.warn(`flow: ${message}; later failures of this kind are not logged`);
|
|
75
|
+
}
|
|
76
|
+
const kindOf = (channel) => (channel ? CHANNEL_KIND[agent.kindOf(channel) ?? ""] ?? "other" : undefined);
|
|
77
|
+
function waitingFor(id) {
|
|
78
|
+
let w = waiting.get(id);
|
|
79
|
+
if (!w)
|
|
80
|
+
waiting.set(id, (w = { inbound: [], spans: [] }));
|
|
81
|
+
return w;
|
|
82
|
+
}
|
|
83
|
+
function child(turn, name, start, end, attributes, failed = false) {
|
|
84
|
+
return span(turn.traceId, name, start, end, attributes, turn.rootId, failed);
|
|
85
|
+
}
|
|
86
|
+
function begin(conversationId, at) {
|
|
87
|
+
finish(conversationId, false, at);
|
|
88
|
+
const w = waiting.get(conversationId);
|
|
89
|
+
waiting.delete(conversationId);
|
|
90
|
+
const inbound = w?.inbound ?? [];
|
|
91
|
+
const turn = { traceId: hex(16), rootId: hex(8), start: Math.min(at, ...inbound.map((m) => m.at)), inbound, spans: [], tools: new Map() };
|
|
92
|
+
for (const make of w?.spans ?? [])
|
|
93
|
+
turn.spans.push(make(turn));
|
|
94
|
+
open.set(conversationId, turn);
|
|
95
|
+
}
|
|
96
|
+
function finish(conversationId, failed, at) {
|
|
97
|
+
const turn = open.get(conversationId);
|
|
98
|
+
if (!turn)
|
|
99
|
+
return;
|
|
100
|
+
open.delete(conversationId);
|
|
101
|
+
record(conversationId, turn, failed, at);
|
|
102
|
+
}
|
|
103
|
+
function record(conversationId, turn, failed, at) {
|
|
104
|
+
const attributes = [attr("gen_ai.operation.name", "invoke_agent"), attr("gen_ai.agent.name", agent.service), ...ids(conversationId)];
|
|
105
|
+
const channel = kindOf(turn.inbound.at(-1)?.channel ?? turn.replyChannel);
|
|
106
|
+
if (channel)
|
|
107
|
+
attributes.push(attr("chinwag.channel", channel));
|
|
108
|
+
const session = testRun ? setting("TEST_SESSION") : undefined;
|
|
109
|
+
if (session)
|
|
110
|
+
attributes.push(attr("chinwag.test.session", session));
|
|
111
|
+
if (turn.inbound.length) {
|
|
112
|
+
attributes.push(attr("chinwag.messages", turn.inbound.length));
|
|
113
|
+
if (turn.inbound[0].messageId)
|
|
114
|
+
attributes.push(attr("chinwag.message_id", turn.inbound[0].messageId));
|
|
115
|
+
if (content)
|
|
116
|
+
attributes.push(attr("chinwag.user_message", clip(turn.inbound.map((m) => m.text).join("\n"))));
|
|
117
|
+
}
|
|
118
|
+
const root = { ...span(turn.traceId, `invoke_agent ${agent.service}`, turn.start, at, attributes, undefined, failed), spanId: turn.rootId };
|
|
119
|
+
enqueue([root, ...turn.spans]);
|
|
120
|
+
}
|
|
121
|
+
/** A message sent outside any turn (one the business wrote, or a rule's answer, sent as is): a trace of its own. */
|
|
122
|
+
function alone(conversationId, at, channel, text) {
|
|
123
|
+
const turn = { traceId: hex(16), rootId: hex(8), start: at, inbound: [], spans: [], tools: new Map(), replyChannel: channel };
|
|
124
|
+
turn.spans.push(reply(turn, at, channel, text));
|
|
125
|
+
record(conversationId, turn, false, at);
|
|
126
|
+
}
|
|
127
|
+
function reply(turn, at, channel, text) {
|
|
128
|
+
turn.replyChannel ??= channel;
|
|
129
|
+
const attributes = [attr("chinwag.channel", channel)];
|
|
130
|
+
if (content && typeof text === "string")
|
|
131
|
+
attributes.push(attr("chinwag.agent_reply", clip(text)));
|
|
132
|
+
return child(turn, `send ${channel}`, at, at, attributes);
|
|
133
|
+
}
|
|
134
|
+
function enqueue(spans) {
|
|
135
|
+
queue.push(spans);
|
|
136
|
+
if (queue.length > MAX_QUEUED) {
|
|
137
|
+
queue.shift();
|
|
138
|
+
warnOnce("queue", "traces could not be sent fast enough and the oldest turns were dropped");
|
|
139
|
+
}
|
|
140
|
+
void drain();
|
|
141
|
+
}
|
|
142
|
+
function drain() {
|
|
143
|
+
sending ??= (async () => {
|
|
144
|
+
while (queue.length)
|
|
145
|
+
await post(queue.splice(0, BATCH).flat());
|
|
146
|
+
})().finally(() => (sending = null));
|
|
147
|
+
return sending;
|
|
148
|
+
}
|
|
149
|
+
async function post(spans) {
|
|
150
|
+
const body = JSON.stringify({ resourceSpans: [{ resource: { attributes: resource }, scopeSpans: [{ scope: { name: "chinwag" }, spans }] }] });
|
|
151
|
+
try {
|
|
152
|
+
const response = await fetch(endpoint, {
|
|
153
|
+
method: "POST",
|
|
154
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${options.key}` },
|
|
155
|
+
body,
|
|
156
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
157
|
+
});
|
|
158
|
+
const answer = await response.text();
|
|
159
|
+
if (!response.ok)
|
|
160
|
+
return warnOnce(`http ${response.status}`, `the traces endpoint refused traces (HTTP ${response.status})`);
|
|
161
|
+
const rejected = Number(parseJson(answer)?.partialSuccess?.rejectedSpans ?? 0);
|
|
162
|
+
if (rejected > 0)
|
|
163
|
+
warnOnce("partial", `the traces endpoint rejected ${rejected} span(s) of a trace`);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
const name = error instanceof Error ? error.name : "error";
|
|
167
|
+
warnOnce(`network ${name}`, `traces could not reach the traces endpoint (${name})`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function handle(e) {
|
|
171
|
+
const id = e.conversationId;
|
|
172
|
+
const turn = open.get(id);
|
|
173
|
+
switch (e.type) {
|
|
174
|
+
case "message.arrived":
|
|
175
|
+
case "turn.context":
|
|
176
|
+
return;
|
|
177
|
+
case "message.in": {
|
|
178
|
+
// A voice note's text is its transcript (and any caption).
|
|
179
|
+
const text = [e.transcript, e.text].filter(Boolean).join(" ");
|
|
180
|
+
const m = { at: e.at, text, channel: String(e.channel ?? ""), messageId: e.ownId ? String(e.messageId) : undefined };
|
|
181
|
+
// Answered with no model call (intercept, a keyword): a trace of its own, never part of a turn.
|
|
182
|
+
if (e.answered)
|
|
183
|
+
return record(id, { traceId: hex(16), rootId: hex(8), start: e.at, inbound: [m], spans: [], tools: new Map() }, false, e.at);
|
|
184
|
+
(turn ?? waitingFor(id)).inbound.push(m);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
case "turn.start":
|
|
188
|
+
return begin(id, e.at);
|
|
189
|
+
case "turn.end":
|
|
190
|
+
return finish(id, false, e.at);
|
|
191
|
+
case "model.error":
|
|
192
|
+
return finish(id, true, e.at);
|
|
193
|
+
case "message.out":
|
|
194
|
+
if (!turn || e.answered)
|
|
195
|
+
return alone(id, e.at, String(e.channel), e.text);
|
|
196
|
+
turn.spans.push(reply(turn, e.at, String(e.channel), e.text));
|
|
197
|
+
return;
|
|
198
|
+
case "model.call": {
|
|
199
|
+
if (!turn)
|
|
200
|
+
return;
|
|
201
|
+
const ms = Number(e.ms ?? 0);
|
|
202
|
+
const usage = e.usage;
|
|
203
|
+
const model = typeof e.model === "string" ? e.model : undefined;
|
|
204
|
+
const attributes = [attr("gen_ai.operation.name", "chat")];
|
|
205
|
+
if (model)
|
|
206
|
+
attributes.push(attr("gen_ai.response.model", model));
|
|
207
|
+
if (usage?.inputTokens !== undefined)
|
|
208
|
+
attributes.push(attr("gen_ai.usage.input_tokens", usage.inputTokens));
|
|
209
|
+
if (usage?.outputTokens !== undefined)
|
|
210
|
+
attributes.push(attr("gen_ai.usage.output_tokens", usage.outputTokens));
|
|
211
|
+
turn.spans.push(child(turn, model ? `chat ${model}` : "chat", e.at - ms, e.at, attributes));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
case "tool.start":
|
|
215
|
+
turn?.tools.set(String(e.callId), { at: e.at, input: e.input });
|
|
216
|
+
return;
|
|
217
|
+
case "tool.end":
|
|
218
|
+
case "tool.detached": {
|
|
219
|
+
if (!turn)
|
|
220
|
+
return;
|
|
221
|
+
const started = turn.tools.get(String(e.callId));
|
|
222
|
+
turn.tools.delete(String(e.callId));
|
|
223
|
+
const attributes = toolAttributes(e, started?.input);
|
|
224
|
+
if (e.type === "tool.detached")
|
|
225
|
+
attributes.push(attr("chinwag.tool.background", true));
|
|
226
|
+
turn.spans.push(child(turn, `execute_tool ${e.tool}`, started?.at ?? e.at - Number(e.ms ?? 0), e.at, attributes, e.ok === false));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
case "task.done": {
|
|
230
|
+
// A background tool finished: its result starts the next turn, which this span joins.
|
|
231
|
+
const make = (t) => child(t, `execute_tool ${e.tool}`, e.at - Number(e.ms ?? 0), e.at, [...toolAttributes(e), attr("chinwag.tool.background", true)], e.ok === false);
|
|
232
|
+
if (turn)
|
|
233
|
+
turn.spans.push(make(turn));
|
|
234
|
+
else
|
|
235
|
+
waitingFor(id).spans.push(make);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function toolAttributes(e, input) {
|
|
241
|
+
const attributes = [attr("gen_ai.operation.name", "execute_tool"), attr("gen_ai.tool.name", String(e.tool)), attr("gen_ai.tool.call.id", String(e.callId))];
|
|
242
|
+
if (content && input !== undefined)
|
|
243
|
+
attributes.push(attr("gen_ai.tool.call.arguments", clip(JSON.stringify(input))));
|
|
244
|
+
if (content && typeof e.output === "string")
|
|
245
|
+
attributes.push(attr("gen_ai.tool.call.result", clip(e.output)));
|
|
246
|
+
return attributes;
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
sink: (event) => {
|
|
250
|
+
try {
|
|
251
|
+
handle(event);
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
warnOnce("sink", `traces skipped an event (${error instanceof Error ? error.name : "error"})`);
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
flush: () => drain(),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
/** The endpoint's ids: the conversation is the person (Flow Framework keeps one per person across channels). */
|
|
261
|
+
function ids(conversationId) {
|
|
262
|
+
return [attr("chinwag.conversation_id", conversationId), attr("chinwag.contact_id", conversationId)];
|
|
263
|
+
}
|
|
264
|
+
function span(traceId, name, start, end, attributes, parentSpanId, failed = false) {
|
|
265
|
+
return {
|
|
266
|
+
traceId,
|
|
267
|
+
spanId: hex(8),
|
|
268
|
+
...(parentSpanId ? { parentSpanId } : {}),
|
|
269
|
+
name,
|
|
270
|
+
kind: 1,
|
|
271
|
+
startTimeUnixNano: nanos(start),
|
|
272
|
+
endTimeUnixNano: nanos(Math.max(start, end)),
|
|
273
|
+
attributes,
|
|
274
|
+
...(failed ? { status: { code: 2 } } : {}),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
function attr(key, value) {
|
|
278
|
+
if (typeof value === "boolean")
|
|
279
|
+
return { key, value: { boolValue: value } };
|
|
280
|
+
if (typeof value === "number")
|
|
281
|
+
return { key, value: Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value } };
|
|
282
|
+
return { key, value: { stringValue: value } };
|
|
283
|
+
}
|
|
284
|
+
const hex = (bytes) => randomBytes(bytes).toString("hex");
|
|
285
|
+
const nanos = (ms) => (BigInt(Math.round(ms)) * 1000000n).toString();
|
|
286
|
+
const clip = (text) => (text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}… [truncated]` : text);
|
|
287
|
+
function allowedUrl(url) {
|
|
288
|
+
try {
|
|
289
|
+
const u = new URL(url);
|
|
290
|
+
return u.protocol === "https:" || (u.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(u.hostname));
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function parseJson(text) {
|
|
297
|
+
try {
|
|
298
|
+
return JSON.parse(text);
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
export interface ToolCall {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
/** The model's arguments as it wrote them (a JSON string). */
|
|
5
|
+
arguments: string;
|
|
6
|
+
}
|
|
7
|
+
export type ChatMessage = {
|
|
8
|
+
role: "user";
|
|
9
|
+
content: string;
|
|
10
|
+
media?: Media[];
|
|
11
|
+
messages?: PersonMessage[];
|
|
12
|
+
} | {
|
|
13
|
+
role: "assistant";
|
|
14
|
+
content: string | null;
|
|
15
|
+
toolCalls?: ToolCall[];
|
|
16
|
+
} | {
|
|
17
|
+
role: "tool";
|
|
18
|
+
toolCallId: string;
|
|
19
|
+
content: string;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* One of the person's messages in a history entry, kept beside its text (never sent to the model):
|
|
23
|
+
* its id (the provider's, or Flow Framework's m1, m2, ...), its channel, and what they wrote or tapped.
|
|
24
|
+
*/
|
|
25
|
+
export interface PersonMessage {
|
|
26
|
+
id: string;
|
|
27
|
+
channel: string;
|
|
28
|
+
text: string;
|
|
29
|
+
/** They tapped a button or list row. */
|
|
30
|
+
choice?: boolean;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A photo or PDF in the person's messages, for the model to read. `data` (base64) is held in memory
|
|
34
|
+
* only while the model is shown it, never stored; without it, the model is told it is no longer shown.
|
|
35
|
+
*/
|
|
36
|
+
export interface Media {
|
|
37
|
+
kind: "image" | "document";
|
|
38
|
+
mime: string;
|
|
39
|
+
name?: string;
|
|
40
|
+
data?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface ToolSpec {
|
|
43
|
+
name: string;
|
|
44
|
+
description: string;
|
|
45
|
+
/** JSON Schema of the tool's input. */
|
|
46
|
+
parameters: Record<string, unknown>;
|
|
47
|
+
}
|
|
48
|
+
export interface ModelRequest {
|
|
49
|
+
system: string;
|
|
50
|
+
messages: ChatMessage[];
|
|
51
|
+
tools: ToolSpec[];
|
|
52
|
+
/** Aborted when the step is no longer wanted (the person sent another message). */
|
|
53
|
+
signal: AbortSignal;
|
|
54
|
+
}
|
|
55
|
+
export interface ModelResponse {
|
|
56
|
+
/** Text outside any tool call. Never shown to the person; kept in history as the agent's note. */
|
|
57
|
+
text: string | null;
|
|
58
|
+
toolCalls: ToolCall[];
|
|
59
|
+
/** costUsd when the provider reports it (OpenRouter does). */
|
|
60
|
+
usage?: {
|
|
61
|
+
inputTokens: number;
|
|
62
|
+
outputTokens: number;
|
|
63
|
+
costUsd?: number;
|
|
64
|
+
};
|
|
65
|
+
/** The model that answered, as the provider names it. */
|
|
66
|
+
model?: string;
|
|
67
|
+
}
|
|
68
|
+
/** Any model behind Flow Framework. `complete` must reject promptly when `signal` aborts. */
|
|
69
|
+
export interface Model {
|
|
70
|
+
complete(request: ModelRequest): Promise<ModelResponse>;
|
|
71
|
+
/** Its context window in tokens, when known: each call is kept well within it (see `history`). */
|
|
72
|
+
contextTokens?: number;
|
|
73
|
+
/** What it reads besides text, as `media` on the person's messages. Without it: text only, and a note for each photo or PDF. */
|
|
74
|
+
reads?: {
|
|
75
|
+
images?: boolean;
|
|
76
|
+
pdfs?: boolean;
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
export interface ListRow {
|
|
80
|
+
id: string;
|
|
81
|
+
title: string;
|
|
82
|
+
description?: string;
|
|
83
|
+
}
|
|
84
|
+
/** What a conversation sends to its channel. */
|
|
85
|
+
export type Outbound = {
|
|
86
|
+
type: "message";
|
|
87
|
+
text: string;
|
|
88
|
+
/** The person's message this one quotes (channels with replies). */
|
|
89
|
+
replyTo?: string;
|
|
90
|
+
/** Reply buttons; a tap comes back as the person's message. */
|
|
91
|
+
buttons?: string[];
|
|
92
|
+
/** A list the person picks one row from. */
|
|
93
|
+
list?: {
|
|
94
|
+
button: string;
|
|
95
|
+
rows: ListRow[];
|
|
96
|
+
};
|
|
97
|
+
/** A button that opens this https page; only on channels with linkButton (elsewhere Flow Framework puts the URL in the text). */
|
|
98
|
+
link?: {
|
|
99
|
+
label: string;
|
|
100
|
+
url: string;
|
|
101
|
+
};
|
|
102
|
+
/** A photo by https URL, the text its caption; only on channels with images (elsewhere the text goes alone). */
|
|
103
|
+
image?: {
|
|
104
|
+
url: string;
|
|
105
|
+
};
|
|
106
|
+
/** The channel's own params (see Channel.params), as the agent filled them in. */
|
|
107
|
+
fields?: Record<string, unknown>;
|
|
108
|
+
}
|
|
109
|
+
/** The agent has picked these messages up (WhatsApp's blue ticks). */
|
|
110
|
+
| {
|
|
111
|
+
type: "read";
|
|
112
|
+
messageIds: string[];
|
|
113
|
+
}
|
|
114
|
+
/** A reaction on one of the person's messages, by the id Flow Framework gave it. */
|
|
115
|
+
| {
|
|
116
|
+
type: "reaction";
|
|
117
|
+
emoji: string;
|
|
118
|
+
messageId: string;
|
|
119
|
+
}
|
|
120
|
+
/** A template the owner set up with the channel (WhatsApp), sent as is, even outside its window. */
|
|
121
|
+
| ({
|
|
122
|
+
type: "template";
|
|
123
|
+
} & Template)
|
|
124
|
+
/** A short line of what the agent is doing, shown in the loading indicator; null clears it. */
|
|
125
|
+
| {
|
|
126
|
+
type: "status";
|
|
127
|
+
text: string | null;
|
|
128
|
+
} | {
|
|
129
|
+
type: "typing";
|
|
130
|
+
on: boolean;
|
|
131
|
+
};
|
|
132
|
+
/** A WhatsApp template as it is sent: its name and the values for its placeholders (Meta's template parameters). */
|
|
133
|
+
export interface Template {
|
|
134
|
+
/** Its name as approved (or the provider's own id for it). */
|
|
135
|
+
name: string;
|
|
136
|
+
language?: string;
|
|
137
|
+
/** The header's value: the text for its {{1}}, or its photo, video or document by https URL or the provider's file id. */
|
|
138
|
+
header?: {
|
|
139
|
+
kind: "text";
|
|
140
|
+
text: string;
|
|
141
|
+
} | {
|
|
142
|
+
kind: "image" | "video" | "document";
|
|
143
|
+
url?: string;
|
|
144
|
+
fileId?: string;
|
|
145
|
+
};
|
|
146
|
+
/** Values for the body's {{1}}, {{2}}, ... in order. */
|
|
147
|
+
body?: string[];
|
|
148
|
+
/** Values for the buttons that take one, by the button's position from 0: a URL button's suffix, a quick reply's payload. */
|
|
149
|
+
buttons?: Array<{
|
|
150
|
+
index: number;
|
|
151
|
+
value: string;
|
|
152
|
+
}>;
|
|
153
|
+
/** The body's values by number ({ "1": "Maria" }), the older form of `body`: give one or the other. */
|
|
154
|
+
variables?: Record<string, string>;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* A WhatsApp template as the owner set it up with Meta, declared in `createAgent({ templates })`:
|
|
158
|
+
* Flow Framework checks every send of it against this, and the playground shows it as WhatsApp would.
|
|
159
|
+
*/
|
|
160
|
+
export interface TemplateDefinition {
|
|
161
|
+
name: string;
|
|
162
|
+
/** Meta's language code: en, en_US, hi. */
|
|
163
|
+
language: string;
|
|
164
|
+
category: "utility" | "marketing" | "authentication";
|
|
165
|
+
/** Text (which may hold one {{1}}), or a photo, video or document sent with each message. */
|
|
166
|
+
header?: {
|
|
167
|
+
kind: "text";
|
|
168
|
+
text: string;
|
|
169
|
+
} | {
|
|
170
|
+
kind: "image" | "video" | "document";
|
|
171
|
+
};
|
|
172
|
+
/** The message, with {{1}}, {{2}}, ... where its values go. */
|
|
173
|
+
body: string;
|
|
174
|
+
footer?: string;
|
|
175
|
+
/** Quick replies come back as the person's tap; a URL may end in {{1}}, its value given at each send. */
|
|
176
|
+
buttons?: TemplateButton[];
|
|
177
|
+
}
|
|
178
|
+
export type TemplateButton = {
|
|
179
|
+
type: "quick_reply";
|
|
180
|
+
text: string;
|
|
181
|
+
} | {
|
|
182
|
+
type: "url";
|
|
183
|
+
text: string;
|
|
184
|
+
url: string;
|
|
185
|
+
} | {
|
|
186
|
+
type: "phone";
|
|
187
|
+
text: string;
|
|
188
|
+
phone: string;
|
|
189
|
+
};
|
|
190
|
+
/** Sends one outbound; a rejection means it did not reach the person. */
|
|
191
|
+
export type Deliver = (outbound: Outbound) => Promise<void> | void;
|
|
192
|
+
/** What the channel a conversation is on can show. */
|
|
193
|
+
export interface ChannelInfo {
|
|
194
|
+
/** Reactions on the person's message (WhatsApp, iMessage, web); plain SMS has none. */
|
|
195
|
+
reactions: boolean;
|
|
196
|
+
/** A live status line in the loading indicator (web and app UIs). */
|
|
197
|
+
status: boolean;
|
|
198
|
+
/** How text is formatted: none (SMS shows markdown raw), WhatsApp's own marks, or Markdown. */
|
|
199
|
+
formatting?: "plain" | "whatsapp" | "markdown";
|
|
200
|
+
/** The most characters one message can carry. */
|
|
201
|
+
maxLength?: number;
|
|
202
|
+
/** A message can quote one of the person's messages. */
|
|
203
|
+
replies?: boolean;
|
|
204
|
+
/** Reply buttons under a message. */
|
|
205
|
+
buttons?: {
|
|
206
|
+
max: number;
|
|
207
|
+
labelMax: number;
|
|
208
|
+
};
|
|
209
|
+
/** A list the person picks one row from. */
|
|
210
|
+
lists?: {
|
|
211
|
+
maxRows: number;
|
|
212
|
+
titleMax: number;
|
|
213
|
+
buttonMax: number;
|
|
214
|
+
};
|
|
215
|
+
/** One button under a message that opens a web page (WhatsApp's call-to-action URL button). */
|
|
216
|
+
linkButton?: {
|
|
217
|
+
labelMax: number;
|
|
218
|
+
};
|
|
219
|
+
/** Photos sent by URL, with the message's text as their caption. */
|
|
220
|
+
images?: {
|
|
221
|
+
captionMax: number;
|
|
222
|
+
};
|
|
223
|
+
/** The channel shows the person when their message has been read. */
|
|
224
|
+
readReceipts?: boolean;
|
|
225
|
+
/**
|
|
226
|
+
* The business may message first only this many hours after the person's last message
|
|
227
|
+
* (WhatsApp: 24); after that, only templates.
|
|
228
|
+
*/
|
|
229
|
+
replyWindowHours?: number;
|
|
230
|
+
}
|
|
231
|
+
/** A message from the person, with what the channel knows about it. */
|
|
232
|
+
export interface Incoming {
|
|
233
|
+
text: string;
|
|
234
|
+
/** The message they replied to (theirs or the agent's), quoted. */
|
|
235
|
+
replyTo?: {
|
|
236
|
+
text: string;
|
|
237
|
+
fromAgent: boolean;
|
|
238
|
+
};
|
|
239
|
+
/** They tapped a button or picked a list row rather than typing. */
|
|
240
|
+
choice?: {
|
|
241
|
+
id?: string;
|
|
242
|
+
title: string;
|
|
243
|
+
};
|
|
244
|
+
/** Photos, voice notes and documents sent with it; `text` is then their caption, or "". */
|
|
245
|
+
attachments?: Attachment[];
|
|
246
|
+
}
|
|
247
|
+
/** A file the person sent. Its bytes are fetched only when the agent can use them. */
|
|
248
|
+
export interface Attachment {
|
|
249
|
+
kind: "image" | "audio" | "document";
|
|
250
|
+
mime: string;
|
|
251
|
+
/** The file name, when the channel gives one (documents). */
|
|
252
|
+
name?: string;
|
|
253
|
+
/** The bytes. A provider fetches its media here with its own credentials: no URL reaches the model. */
|
|
254
|
+
data(): Promise<Uint8Array>;
|
|
255
|
+
}
|
|
256
|
+
/** Turns a voice note into text, with whatever speech-to-text the agent's author chose. */
|
|
257
|
+
export type Transcribe = (audio: {
|
|
258
|
+
mime: string;
|
|
259
|
+
data: Uint8Array;
|
|
260
|
+
}) => Promise<string>;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const ACCOUNT_COMMANDS: Set<string>;
|
|
2
|
+
export declare const ACCOUNT_LINES = " flow login [--wait | --no-wait] [--timeout <s>] [--force] [--no-browser] [--json]\n Sign in to flow.engineer: opens the browser, prints the link and code,\n and waits up to --timeout (default 120) for approval; --wait goes on\n waiting for the same code, --no-wait returns at once\n flow logout [--json] End this machine's sign-in and delete its token\n flow whoami [--json] Show the account you are signed in as\n flow setup [--replace] [--ci] [--json]\n Write this agent's keys (test cloud, traces, Flow Messaging) and each\n open channel's sender into ./.env (in an agent's folder: package.json)\n flow link [--channel telegram|whatsapp|imessage] [--timeout <s>] [--no-browser] [--json]\n Print the link that opens a chat with this agent from a phone; signs in\n and runs setup first when this folder is not set up yet";
|
|
3
|
+
/** Runs one account command; resolves to the exit status. */
|
|
4
|
+
export declare function accountCli(command: string, args: string[]): Promise<number>;
|