@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,280 @@
|
|
|
1
|
+
// `flow show <run-id|last>`: one run's full record from the test cloud (the transcript, the tool
|
|
2
|
+
// calls the simulator played, every check with the judge's reason), so a coding agent can read why a
|
|
3
|
+
// run failed without a browser. The agent's id comes from .flow/last-session.json, and the read
|
|
4
|
+
// carries the agent's CI key: GET {cloud}/v1/agents/{agent}/tests/runs/{run}.
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { parseArgs } from "node:util";
|
|
8
|
+
import { OBSERVE_URL, setting } from "../../src/traces.js";
|
|
9
|
+
import { RESULTS_DIR, resultsFile } from "../../src/names.js";
|
|
10
|
+
import { loadEnv } from "./report.js";
|
|
11
|
+
/** Where every session's results go, in the agent's folder; `flow new` git-ignores .flow/. */
|
|
12
|
+
export const LAST_SESSION = join(RESULTS_DIR, "last-session.json");
|
|
13
|
+
/** The last session's file in dir: LAST_SESSION, else, before a run has written it, the one from before the rename (.chinwag/). */
|
|
14
|
+
export function lastSessionIn(dir) {
|
|
15
|
+
return resultsFile(dir, "last-session.json");
|
|
16
|
+
}
|
|
17
|
+
export const SHOW_LINE = ` flow show <run-id|last> [--json]
|
|
18
|
+
Print one run's record from the test cloud: its checks with the
|
|
19
|
+
judge's reasons, the transcript and the tool calls (flow show --help)`;
|
|
20
|
+
const SHOW_USAGE = `Usage:
|
|
21
|
+
flow show <run-id|last> [options]
|
|
22
|
+
|
|
23
|
+
Prints one run of the last \`flow test\` (or any run of this agent, by the id on its PASS or
|
|
24
|
+
FAIL line): verdict, every check with the judge's reason, then the conversation in time order with
|
|
25
|
+
each tool call the simulator played and what it changed. \`last\` is the first run that did not
|
|
26
|
+
pass in .flow/last-session.json (its first run when all passed).
|
|
27
|
+
|
|
28
|
+
Options:
|
|
29
|
+
--json The record exactly as the cloud answered it, on stdout
|
|
30
|
+
--cloud <url> The test cloud (default: FLOW_FRAMEWORK_CLOUD, else https://flow.engineer/observe)
|
|
31
|
+
--dotenv <path> Read the environment from this file instead of the folder's .env
|
|
32
|
+
-h, --help Show this help
|
|
33
|
+
|
|
34
|
+
Needs FLOW_FRAMEWORK_CLOUD_KEY (the agent's CI key, also read from .env), and the agent's id that
|
|
35
|
+
\`flow test\` keeps in .flow/last-session.json.
|
|
36
|
+
|
|
37
|
+
Exit status:
|
|
38
|
+
0 Printed
|
|
39
|
+
1 A wrong flag, no key or last session, no such run (404), or past the 30 days a run is kept (410)
|
|
40
|
+
2 The cloud did not answer, answered 5xx, or answered something that is not a run's record
|
|
41
|
+
3 The cloud refused the key (401, 403) or is busy (429)`;
|
|
42
|
+
/** `flow show`: returns the exit status. */
|
|
43
|
+
export async function showCli(args) {
|
|
44
|
+
let parsed;
|
|
45
|
+
try {
|
|
46
|
+
parsed = parseArgs({ args, allowPositionals: true, strict: true, options: {
|
|
47
|
+
json: { type: "boolean", default: false },
|
|
48
|
+
cloud: { type: "string" },
|
|
49
|
+
dotenv: { type: "string" },
|
|
50
|
+
help: { type: "boolean", short: "h", default: false },
|
|
51
|
+
} });
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
const { code, message } = error;
|
|
55
|
+
const unknown = code === "ERR_PARSE_ARGS_UNKNOWN_OPTION" ? /'([^']+)'/.exec(message)?.[1] : undefined;
|
|
56
|
+
console.error(`flow: ${unknown ? `unknown option ${unknown}` : message.split(". ")[0]}\n\n${SHOW_USAGE}`);
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
const { values, positionals } = parsed;
|
|
60
|
+
if (values.help) {
|
|
61
|
+
console.log(SHOW_USAGE);
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
if (positionals.length !== 1) {
|
|
65
|
+
console.error(`flow show takes one run id, or last.\n\n${SHOW_USAGE}`);
|
|
66
|
+
return 1;
|
|
67
|
+
}
|
|
68
|
+
const envProblem = loadEnv(values.dotenv);
|
|
69
|
+
if (envProblem) {
|
|
70
|
+
console.error(envProblem);
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
const cloud = (values.cloud || setting("CLOUD") || OBSERVE_URL).replace(/\/+$/, "");
|
|
74
|
+
const key = setting("CLOUD_KEY") || undefined;
|
|
75
|
+
if (!key) {
|
|
76
|
+
console.error(`flow show reads the run with the agent's CI key: set FLOW_FRAMEWORK_CLOUD_KEY.${cloud === OBSERVE_URL ? ` Make a key at ${OBSERVE_URL}: your agent, Developers.` : ""}`);
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
const last = readLast();
|
|
80
|
+
if (typeof last === "string") {
|
|
81
|
+
console.error(last);
|
|
82
|
+
return 1;
|
|
83
|
+
}
|
|
84
|
+
let runId = positionals[0];
|
|
85
|
+
if (runId === "last") {
|
|
86
|
+
const pickedRun = pickLast(last);
|
|
87
|
+
if (!pickedRun) {
|
|
88
|
+
console.error(`${LAST_SESSION} has no run ids: run flow test again, then flow show last.`);
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
runId = pickedRun;
|
|
92
|
+
}
|
|
93
|
+
const id = /^[A-Za-z0-9_-]{1,100}$/;
|
|
94
|
+
if (!id.test(runId)) {
|
|
95
|
+
console.error(`"${runId}" is not a run id: use the id on a run's PASS or FAIL line (trn_...), or last.`);
|
|
96
|
+
return 1;
|
|
97
|
+
}
|
|
98
|
+
if (!id.test(last.agent)) {
|
|
99
|
+
console.error(`${LAST_SESSION} names an agent id that is not one: run flow test again.`);
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
const url = `${cloud}/v1/agents/${last.agent}/tests/runs/${runId}`;
|
|
103
|
+
let status;
|
|
104
|
+
let text;
|
|
105
|
+
try {
|
|
106
|
+
const response = await fetch(url, { headers: { authorization: `Bearer ${key}`, accept: "application/json" }, signal: AbortSignal.timeout(30_000) });
|
|
107
|
+
status = response.status;
|
|
108
|
+
text = await response.text();
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
const cause = error.cause;
|
|
112
|
+
const why = error.name === "TimeoutError" ? "no answer in 30 s" : cause?.code ?? error.message;
|
|
113
|
+
console.error(`Could not reach ${cloud}: ${why}.`);
|
|
114
|
+
return 2;
|
|
115
|
+
}
|
|
116
|
+
if (status < 200 || status >= 300)
|
|
117
|
+
return refused(status, text, runId);
|
|
118
|
+
let record;
|
|
119
|
+
try {
|
|
120
|
+
record = JSON.parse(text);
|
|
121
|
+
if (!record || typeof record !== "object" || Array.isArray(record))
|
|
122
|
+
throw new Error("not an object");
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
console.error(`The cloud's answer for ${runId} was not a run's record (${status}).`);
|
|
126
|
+
return 2;
|
|
127
|
+
}
|
|
128
|
+
if (values.json)
|
|
129
|
+
process.stdout.write(text);
|
|
130
|
+
else
|
|
131
|
+
console.log(render(record, runId).join("\n"));
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
/** The last session's results, or what to do when there are none or they name no agent. */
|
|
135
|
+
function readLast() {
|
|
136
|
+
const path = lastSessionIn(process.cwd());
|
|
137
|
+
if (!existsSync(path))
|
|
138
|
+
return `No ${LAST_SESSION} in ${process.cwd()}: run flow test first; flow show reads the agent's id from it.`;
|
|
139
|
+
let last;
|
|
140
|
+
try {
|
|
141
|
+
last = JSON.parse(readFileSync(path, "utf8"));
|
|
142
|
+
if (!Array.isArray(last.runs))
|
|
143
|
+
throw new Error("it has no runs");
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
return `${LAST_SESSION} could not be read (${error.message}): run flow test again.`;
|
|
147
|
+
}
|
|
148
|
+
if (!last.agent)
|
|
149
|
+
return `${LAST_SESSION} has no agent id: run flow test once more against a current cloud, which names it, then flow show.`;
|
|
150
|
+
return last;
|
|
151
|
+
}
|
|
152
|
+
/** The first run that did not pass, else the first run; says which on stderr and names the others that did not pass. */
|
|
153
|
+
function pickLast(last) {
|
|
154
|
+
const withId = last.runs.filter((r) => r.runId);
|
|
155
|
+
const notPassed = withId.filter((r) => r.verdict === "failed" || r.verdict === "errored");
|
|
156
|
+
const chosen = notPassed[0] ?? withId[0];
|
|
157
|
+
if (!chosen)
|
|
158
|
+
return undefined;
|
|
159
|
+
console.error(`Showing ${chosen.scenario} #${chosen.run} (${chosen.channel}), ${chosen.verdict}: ${chosen.runId}`);
|
|
160
|
+
for (const r of notPassed.slice(1))
|
|
161
|
+
console.error(` also did not pass: flow show ${r.runId} (${r.scenario} #${r.run}, ${r.channel}, ${r.verdict})`);
|
|
162
|
+
return chosen.runId;
|
|
163
|
+
}
|
|
164
|
+
/** The cloud's refusal on stderr, and its exit status. */
|
|
165
|
+
function refused(status, text, runId) {
|
|
166
|
+
let error;
|
|
167
|
+
try {
|
|
168
|
+
const body = JSON.parse(text).error;
|
|
169
|
+
error = typeof body === "string" ? { type: body } : body;
|
|
170
|
+
}
|
|
171
|
+
catch { /* the body was not JSON */ }
|
|
172
|
+
const said = (error?.type ? `${error.type}${error.message ? `: ${error.message}` : ""}` : text.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim().slice(0, 180)).replace(/\.$/, "");
|
|
173
|
+
const meaning = status === 404 ? ` No run ${runId} for this agent.` : status === 410 ? ` Run ${runId} is past the 30 days the cloud keeps runs.` : "";
|
|
174
|
+
console.error(`The cloud answered ${status}${said ? ` ${said}` : ""}.${meaning}${error?.hint ? `\n${error.hint}` : ""}`);
|
|
175
|
+
if (status === 401 || status === 403 || status === 429)
|
|
176
|
+
return 3;
|
|
177
|
+
if (status >= 500 || !error)
|
|
178
|
+
return 2;
|
|
179
|
+
return 1;
|
|
180
|
+
}
|
|
181
|
+
/** At most `max` characters of a value as JSON. */
|
|
182
|
+
function brief(value, max = 300) {
|
|
183
|
+
const json = value === undefined ? "" : typeof value === "string" ? JSON.stringify(value) : JSON.stringify(value) ?? String(value);
|
|
184
|
+
return json.length > max ? `${json.slice(0, max)}…` : json;
|
|
185
|
+
}
|
|
186
|
+
/** The labels a button row or a list shows, whatever shape the record keeps them in. */
|
|
187
|
+
function labels(value) {
|
|
188
|
+
if (typeof value === "string")
|
|
189
|
+
return [value];
|
|
190
|
+
if (Array.isArray(value))
|
|
191
|
+
return value.flatMap(labels);
|
|
192
|
+
if (!value || typeof value !== "object")
|
|
193
|
+
return [];
|
|
194
|
+
const o = value;
|
|
195
|
+
for (const field of ["title", "label", "text"])
|
|
196
|
+
if (typeof o[field] === "string")
|
|
197
|
+
return [o[field]];
|
|
198
|
+
return [...labels(o.sections), ...labels(o.rows)];
|
|
199
|
+
}
|
|
200
|
+
/** The run as text: header, checks (failed first), the conversation and tool calls in time order, then detail and the watch link. */
|
|
201
|
+
function render(record, runId) {
|
|
202
|
+
const out = [];
|
|
203
|
+
const s = record.summary ?? {};
|
|
204
|
+
const checks = record.checks ?? [];
|
|
205
|
+
const passed = s.passed ?? (checks.length > 0 && checks.every((c) => c.pass));
|
|
206
|
+
// An error (judge_failed, spend_cap, ...) or run_failed: the cloud could not play or judge the run.
|
|
207
|
+
const error = record.error ?? s.error;
|
|
208
|
+
const verdict = error || s.ended === "run_failed" ? "ERROR" : passed ? "PASS" : "FAIL";
|
|
209
|
+
const where = record.scenario?.path ?? record.scenario?.id ?? "?";
|
|
210
|
+
out.push(`${verdict} ${where}${record.scenario?.title ? ` (${record.scenario.title})` : ""}`);
|
|
211
|
+
out.push(` channel ${record.channel ?? "?"}, run ${record.run ?? "?"}, ${record.runId ?? runId}; ended: ${s.ended ?? "?"}${s.turns === undefined ? "" : `, ${s.turns} turn${s.turns === 1 ? "" : "s"}`}`);
|
|
212
|
+
if (error)
|
|
213
|
+
out.push(` error: ${typeof error === "string" ? error : brief(error)} (the cloud could not play or judge this run; it says nothing about the agent)`);
|
|
214
|
+
if (checks.length) {
|
|
215
|
+
out.push("", "Checks:");
|
|
216
|
+
for (const c of [...checks.filter((c) => !c.pass), ...checks.filter((c) => c.pass)]) {
|
|
217
|
+
out.push(` ${c.pass ? "✓" : "✗"} ${c.kind ?? "check"}: ${c.text ?? ""}`);
|
|
218
|
+
if (c.reason)
|
|
219
|
+
out.push(...indent(c.reason, " "));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const start = record.startedAt ?? Math.min(...(record.transcript ?? []).map((l) => l.at ?? Infinity), ...(record.toolCalls ?? []).map((t) => t.at ?? Infinity));
|
|
223
|
+
const when = (at) => (at === undefined || !Number.isFinite(start) ? " " : `+${((at - start) / 1000).toFixed(1)}s`.padStart(8));
|
|
224
|
+
const items = [];
|
|
225
|
+
for (const line of record.transcript ?? []) {
|
|
226
|
+
const t = when(line.at);
|
|
227
|
+
let lines;
|
|
228
|
+
if (line.kind === "in") {
|
|
229
|
+
const files = (line.attachments ?? []).map((a) => {
|
|
230
|
+
const f = a;
|
|
231
|
+
return `[${f.kind ?? "file"}: ${f.file ?? f.name ?? "attached"}]`;
|
|
232
|
+
});
|
|
233
|
+
lines = indent([line.text ?? "", ...files].filter(Boolean).join(" "), `${t} person: `, " ".repeat(t.length + 9));
|
|
234
|
+
}
|
|
235
|
+
else if (line.kind === "out") {
|
|
236
|
+
const extra = [];
|
|
237
|
+
const buttons = labels(line.buttons);
|
|
238
|
+
if (buttons.length)
|
|
239
|
+
extra.push(`[buttons: ${buttons.join(" | ")}]`);
|
|
240
|
+
const list = labels(line.list);
|
|
241
|
+
if (list.length)
|
|
242
|
+
extra.push(`[list: ${list.join(" | ")}]`);
|
|
243
|
+
if (line.link)
|
|
244
|
+
extra.push(`[link: ${typeof line.link === "string" ? line.link : line.link.url ?? brief(line.link, 120)}]`);
|
|
245
|
+
if (line.image)
|
|
246
|
+
extra.push("[image]");
|
|
247
|
+
if (line.template)
|
|
248
|
+
extra.push(`[template: ${typeof line.template === "string" ? line.template : line.template.name ?? brief(line.template, 120)}]`);
|
|
249
|
+
lines = indent([line.text ?? "", ...extra].filter(Boolean).join(" "), `${t} agent: `, " ".repeat(t.length + 8));
|
|
250
|
+
}
|
|
251
|
+
else if (line.kind === "reaction") {
|
|
252
|
+
lines = [`${t} ${line.by ?? "someone"} reacted ${line.emoji ?? "?"} to ${line.messageId ?? "a message"}`];
|
|
253
|
+
}
|
|
254
|
+
else
|
|
255
|
+
continue;
|
|
256
|
+
items.push({ at: line.at ?? Infinity, order: items.length, lines });
|
|
257
|
+
}
|
|
258
|
+
for (const call of record.toolCalls ?? []) {
|
|
259
|
+
const t = when(call.at);
|
|
260
|
+
const result = call.ok === false ? `error: ${typeof call.error === "string" ? call.error : brief(call.error)}` : brief(call.output);
|
|
261
|
+
const notes = [call.kind, "simulated", call.latencyMs === undefined ? undefined : `${call.latencyMs} ms`].filter(Boolean).join(", ");
|
|
262
|
+
const lines = [`${t} tool ${call.tool ?? "?"}(${brief(call.input)}) → ${result} [${notes}]`];
|
|
263
|
+
for (const change of call.changes ?? [])
|
|
264
|
+
lines.push(`${" ".repeat(t.length)} changes: ${change}`);
|
|
265
|
+
items.push({ at: call.at ?? Infinity, order: items.length, lines });
|
|
266
|
+
}
|
|
267
|
+
items.sort((a, b) => a.at - b.at || a.order - b.order);
|
|
268
|
+
if (items.length)
|
|
269
|
+
out.push("", "Conversation:", ...items.flatMap((i) => i.lines));
|
|
270
|
+
if (record.detail !== undefined && record.detail !== null && record.detail !== "") {
|
|
271
|
+
out.push("", ...indent(typeof record.detail === "string" ? record.detail : JSON.stringify(record.detail, null, 2), "Detail: ", " "));
|
|
272
|
+
}
|
|
273
|
+
if (s.watch)
|
|
274
|
+
out.push("", `Watch: ${s.watch}`);
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
277
|
+
/** Text over several lines: the first after `first`, the rest after `rest`. */
|
|
278
|
+
function indent(text, first, rest = first) {
|
|
279
|
+
return text.split("\n").map((line, i) => `${i === 0 ? first : rest}${line}`);
|
|
280
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { z } from "../../src/index.ts";
|
|
2
|
+
export interface SuiteFile {
|
|
3
|
+
/** Relative to the tests folder: scenarios/latte-correction.yaml, tools/place_order.yaml. */
|
|
4
|
+
path: string;
|
|
5
|
+
hash: string;
|
|
6
|
+
content: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function hashOf(content: string): string;
|
|
9
|
+
/** Every scenario and tool file under `testsDir`, in a stable order. */
|
|
10
|
+
export declare function loadSuite(testsDir: string): SuiteFile[];
|
|
11
|
+
/** One hash for the whole suite: runs are compared only when it matches. */
|
|
12
|
+
export declare function suiteHash(files: Array<{
|
|
13
|
+
path: string;
|
|
14
|
+
hash: string;
|
|
15
|
+
}>): string;
|
|
16
|
+
/** What is wrong with the `{{ now ... }}` placeholders in `text`, or null. A placeholder is the test's clock
|
|
17
|
+
* (`{{now}}`, `{{now + 7d | date}}`, `{{now + 6d @ 10:00}}`), filled by the cloud per run; any other `{{...}}` is left alone. */
|
|
18
|
+
export declare function placeholderProblem(text: string): string | null;
|
|
19
|
+
/** A tool call the run must (`calls`) or must not (`never`) make: a tool name, or the tool with input fields it must have. */
|
|
20
|
+
declare const expectedCall: z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
21
|
+
tool: z.ZodString;
|
|
22
|
+
args: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
23
|
+
}, z.core.$strict>]>;
|
|
24
|
+
declare const scenarioSchema: z.ZodObject<{
|
|
25
|
+
id: z.ZodString;
|
|
26
|
+
title: z.ZodOptional<z.ZodString>;
|
|
27
|
+
channels: z.ZodArray<z.ZodEnum<{
|
|
28
|
+
imessage: "imessage";
|
|
29
|
+
sms: "sms";
|
|
30
|
+
telegram: "telegram";
|
|
31
|
+
web: "web";
|
|
32
|
+
whatsapp: "whatsapp";
|
|
33
|
+
}>>;
|
|
34
|
+
timezone: z.ZodOptional<z.ZodString>;
|
|
35
|
+
now: z.ZodOptional<z.ZodPreprocess<z.ZodString, unknown>>;
|
|
36
|
+
person: z.ZodObject<{
|
|
37
|
+
persona: z.ZodString;
|
|
38
|
+
goal: z.ZodString;
|
|
39
|
+
knows: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
40
|
+
}, z.core.$strip>;
|
|
41
|
+
opening: z.ZodArray<z.ZodObject<{
|
|
42
|
+
say: z.ZodDefault<z.ZodString>;
|
|
43
|
+
attach: z.ZodOptional<z.ZodObject<{
|
|
44
|
+
kind: z.ZodEnum<{
|
|
45
|
+
audio: "audio";
|
|
46
|
+
document: "document";
|
|
47
|
+
image: "image";
|
|
48
|
+
}>;
|
|
49
|
+
file: z.ZodString;
|
|
50
|
+
}, z.core.$strip>>;
|
|
51
|
+
after_ms: z.ZodOptional<z.ZodNumber>;
|
|
52
|
+
}, z.core.$strip>>;
|
|
53
|
+
world: z.ZodOptional<z.ZodString>;
|
|
54
|
+
max_turns: z.ZodDefault<z.ZodNumber>;
|
|
55
|
+
expect: z.ZodDefault<z.ZodObject<{
|
|
56
|
+
outcome: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
57
|
+
judge: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
58
|
+
calls: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
59
|
+
tool: z.ZodString;
|
|
60
|
+
args: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
61
|
+
}, z.core.$strict>]>>>;
|
|
62
|
+
never: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
63
|
+
tool: z.ZodString;
|
|
64
|
+
args: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
65
|
+
}, z.core.$strict>]>>>;
|
|
66
|
+
}, z.core.$strip>>;
|
|
67
|
+
}, z.core.$strip>;
|
|
68
|
+
declare const toolFileSchema: z.ZodObject<{
|
|
69
|
+
tool: z.ZodString;
|
|
70
|
+
behaviour: z.ZodOptional<z.ZodString>;
|
|
71
|
+
responses: z.ZodArray<z.ZodObject<{
|
|
72
|
+
when: z.ZodString;
|
|
73
|
+
latency_ms: z.ZodNumber;
|
|
74
|
+
input: z.ZodOptional<z.ZodUnknown>;
|
|
75
|
+
output: z.ZodOptional<z.ZodUnknown>;
|
|
76
|
+
error: z.ZodOptional<z.ZodString>;
|
|
77
|
+
}, z.core.$strip>>;
|
|
78
|
+
}, z.core.$strip>;
|
|
79
|
+
/** The type of a file a scenario attaches, from its extension. */
|
|
80
|
+
export declare function mimeOf(path: string): string;
|
|
81
|
+
export type Scenario = z.infer<typeof scenarioSchema>;
|
|
82
|
+
export type ToolFile = z.infer<typeof toolFileSchema>;
|
|
83
|
+
export type ExpectedCall = z.infer<typeof expectedCall>;
|
|
84
|
+
/** Parse and check a scenario. The error has one line per problem: `file:line: field: what is wrong`. */
|
|
85
|
+
export declare function parseScenario(file: Pick<SuiteFile, "path" | "content">): Scenario;
|
|
86
|
+
export declare function parseToolFile(file: Pick<SuiteFile, "path" | "content">): ToolFile;
|
|
87
|
+
export {};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// A test suite on disk: tests/scenarios/*.yaml and tests/tools/*.yaml, each file hashed by its
|
|
2
|
+
// content so the cloud stores it once and a run records exactly which tests it ran.
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
5
|
+
import { extname, isAbsolute, join } from "node:path";
|
|
6
|
+
import { isMap, isSeq, LineCounter, parseDocument } from "yaml";
|
|
7
|
+
import { z } from "../../src/index.js";
|
|
8
|
+
export function hashOf(content) {
|
|
9
|
+
return createHash("sha256").update(content).digest("hex");
|
|
10
|
+
}
|
|
11
|
+
/** Every scenario and tool file under `testsDir`, in a stable order. */
|
|
12
|
+
export function loadSuite(testsDir) {
|
|
13
|
+
const files = [];
|
|
14
|
+
for (const folder of ["scenarios", "tools"]) {
|
|
15
|
+
const dir = join(testsDir, folder);
|
|
16
|
+
if (!existsSync(dir))
|
|
17
|
+
continue;
|
|
18
|
+
for (const name of readdirSync(dir).filter((n) => n.endsWith(".yaml")).sort()) {
|
|
19
|
+
const content = readFileSync(join(dir, name), "utf8");
|
|
20
|
+
files.push({ path: `${folder}/${name}`, hash: hashOf(content), content });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return files;
|
|
24
|
+
}
|
|
25
|
+
/** One hash for the whole suite: runs are compared only when it matches. */
|
|
26
|
+
export function suiteHash(files) {
|
|
27
|
+
return hashOf(files.map((f) => `${f.path}:${f.hash}`).sort().join("\n"));
|
|
28
|
+
}
|
|
29
|
+
/** `{{ now [±<n>m|h|d|w]* [@ HH:MM] [| iso|date|time|weekday] }}`, spaces optional between the parts. */
|
|
30
|
+
const CLOCK = /^\s*now(?:\s*[+-]\s*\d+[mhdw])*(?:\s*@\s*(?:[01]\d|2[0-3]):[0-5]\d)?(?:\s*\|\s*(?:iso|date|time|weekday))?\s*$/;
|
|
31
|
+
/** What is wrong with the `{{ now ... }}` placeholders in `text`, or null. A placeholder is the test's clock
|
|
32
|
+
* (`{{now}}`, `{{now + 7d | date}}`, `{{now + 6d @ 10:00}}`), filled by the cloud per run; any other `{{...}}` is left alone. */
|
|
33
|
+
export function placeholderProblem(text) {
|
|
34
|
+
for (const [whole, inner] of text.matchAll(/\{\{([\s\S]*?)\}\}/g)) {
|
|
35
|
+
if (/^\s*now\b/.test(inner) && !CLOCK.test(inner)) {
|
|
36
|
+
return `unknown placeholder ${whole}: write it like {{now + 3d}}, {{now - 2h | time}} or {{now + 1w @ 10:00 | date}}`;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
/** Every string in `value` whose placeholders are wrong, with where it is under `value`. */
|
|
42
|
+
function* placeholderProblems(value, path = []) {
|
|
43
|
+
if (typeof value === "string") {
|
|
44
|
+
const problem = placeholderProblem(value);
|
|
45
|
+
if (problem)
|
|
46
|
+
yield { path, problem };
|
|
47
|
+
}
|
|
48
|
+
else if (Array.isArray(value)) {
|
|
49
|
+
for (let i = 0; i < value.length; i++)
|
|
50
|
+
yield* placeholderProblems(value[i], [...path, i]);
|
|
51
|
+
}
|
|
52
|
+
else if (value && typeof value === "object") {
|
|
53
|
+
for (const [key, item] of Object.entries(value))
|
|
54
|
+
yield* placeholderProblems(item, [...path, key]);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** A value whose strings may hold placeholders, each checked. */
|
|
58
|
+
const withClock = (schema) => schema.superRefine((value, ctx) => {
|
|
59
|
+
for (const { path, problem } of placeholderProblems(value))
|
|
60
|
+
ctx.addIssue({ code: "custom", message: problem, path });
|
|
61
|
+
});
|
|
62
|
+
/** A time zone Intl knows. */
|
|
63
|
+
function isTimeZone(zone) {
|
|
64
|
+
try {
|
|
65
|
+
new Intl.DateTimeFormat("en-US", { timeZone: zone });
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** YYYY-MM-DDTHH:MM[:SS], optionally Z or ±HH:MM, naming a real moment. */
|
|
73
|
+
function isDateTime(text) {
|
|
74
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?(?:Z|[+-](\d{2}):(\d{2}))?$/.exec(text);
|
|
75
|
+
if (!m)
|
|
76
|
+
return false;
|
|
77
|
+
const [y, mo, d, h, mi, s = 0, oh = 0, om = 0] = m.slice(1).map((part) => (part === undefined ? undefined : Number(part)));
|
|
78
|
+
const day = new Date(Date.UTC(y, mo - 1, d));
|
|
79
|
+
return day.getUTCFullYear() === y && day.getUTCMonth() === mo - 1 && day.getUTCDate() === d && h < 24 && mi < 60 && s < 60 && oh < 24 && om < 60;
|
|
80
|
+
}
|
|
81
|
+
/** A tool call the run must (`calls`) or must not (`never`) make: a tool name, or the tool with input fields it must have. */
|
|
82
|
+
const expectedCall = z.union([
|
|
83
|
+
z.string().min(1),
|
|
84
|
+
z.strictObject({
|
|
85
|
+
tool: z.string().min(1),
|
|
86
|
+
/** Each field must equal the call's input field: nested objects in part, arrays and strings exactly. */
|
|
87
|
+
args: withClock(z.record(z.string(), z.unknown())).optional(),
|
|
88
|
+
}),
|
|
89
|
+
], { error: "a tool name, or { tool: <name>, args: { <field>: <value> } }" });
|
|
90
|
+
const scenarioSchema = z.object({
|
|
91
|
+
id: z.string().min(1),
|
|
92
|
+
title: z.string().optional(),
|
|
93
|
+
/** Every platform it runs on; each is a run of its own, with its own result. */
|
|
94
|
+
channels: z.array(z.enum(["sms", "whatsapp", "imessage", "telegram", "web"])).min(1),
|
|
95
|
+
/** The IANA time zone of the test's clock; UTC when left out. */
|
|
96
|
+
timezone: z.string().refine(isTimeZone, "not a time zone: use an IANA name such as America/New_York or UTC").optional(),
|
|
97
|
+
/** The test's clock when each run starts, local to `timezone` unless it has an offset; the real time when left out.
|
|
98
|
+
* YAML's default schema reads it as a string; a Date from another schema is taken as its UTC time. */
|
|
99
|
+
now: z
|
|
100
|
+
.preprocess((v) => (v instanceof Date ? v.toISOString().replace(/\.\d+Z$/, "Z") : v), z.string().refine(isDateTime, "not a date and time: write YYYY-MM-DDTHH:MM, with :SS, Z or an offset such as -04:00 if needed"))
|
|
101
|
+
.optional(),
|
|
102
|
+
person: z.object({
|
|
103
|
+
persona: withClock(z.string()),
|
|
104
|
+
goal: withClock(z.string()),
|
|
105
|
+
knows: z.array(withClock(z.string())).optional(),
|
|
106
|
+
}),
|
|
107
|
+
opening: z
|
|
108
|
+
.array(z
|
|
109
|
+
.object({
|
|
110
|
+
say: withClock(z.string()).default(""),
|
|
111
|
+
/** A file the person sends with it (say is its caption): a path in the tests folder. */
|
|
112
|
+
attach: z.object({ kind: z.enum(["image", "audio", "document"]), file: z.string().refine(inTests, "a path inside the tests folder, such as fixtures/receipt.jpg") }).optional(),
|
|
113
|
+
after_ms: z.number().int().min(0).optional(),
|
|
114
|
+
})
|
|
115
|
+
.refine((step) => step.say || step.attach, "say something, attach a file, or both"))
|
|
116
|
+
.min(1),
|
|
117
|
+
world: withClock(z.string()).optional(),
|
|
118
|
+
max_turns: z.number().int().min(1).max(50).default(8),
|
|
119
|
+
expect: z
|
|
120
|
+
.object({
|
|
121
|
+
outcome: z.array(withClock(z.string())).optional(),
|
|
122
|
+
judge: z.array(withClock(z.string())).optional(),
|
|
123
|
+
/** Each must be called at least once in the run; checked by code from the recorded calls. */
|
|
124
|
+
calls: z.array(expectedCall).optional(),
|
|
125
|
+
/** None may be called. */
|
|
126
|
+
never: z.array(expectedCall).optional(),
|
|
127
|
+
})
|
|
128
|
+
.default({}),
|
|
129
|
+
});
|
|
130
|
+
const toolFileSchema = z.object({
|
|
131
|
+
tool: z.string().min(1),
|
|
132
|
+
behaviour: z.string().optional(),
|
|
133
|
+
responses: z
|
|
134
|
+
.array(z.object({
|
|
135
|
+
when: z.string(),
|
|
136
|
+
latency_ms: z.number().int().min(0),
|
|
137
|
+
input: withClock(z.unknown()).optional(),
|
|
138
|
+
output: withClock(z.unknown()).optional(),
|
|
139
|
+
error: withClock(z.string()).optional(),
|
|
140
|
+
}))
|
|
141
|
+
.min(1),
|
|
142
|
+
});
|
|
143
|
+
/** A relative path that stays inside the tests folder. */
|
|
144
|
+
function inTests(path) {
|
|
145
|
+
return !!path && !isAbsolute(path) && !path.split(/[\\/]/).includes("..");
|
|
146
|
+
}
|
|
147
|
+
/** The type of a file a scenario attaches, from its extension. */
|
|
148
|
+
export function mimeOf(path) {
|
|
149
|
+
const types = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", webp: "image/webp", gif: "image/gif", pdf: "application/pdf", ogg: "audio/ogg", opus: "audio/ogg", mp3: "audio/mpeg", m4a: "audio/mp4", wav: "audio/wav" };
|
|
150
|
+
return types[extname(path).slice(1).toLowerCase()] ?? "application/octet-stream";
|
|
151
|
+
}
|
|
152
|
+
/** Parse and check a scenario. The error has one line per problem: `file:line: field: what is wrong`. */
|
|
153
|
+
export function parseScenario(file) {
|
|
154
|
+
return parseFile(scenarioSchema, file);
|
|
155
|
+
}
|
|
156
|
+
export function parseToolFile(file) {
|
|
157
|
+
return parseFile(toolFileSchema, file);
|
|
158
|
+
}
|
|
159
|
+
function parseFile(schema, file) {
|
|
160
|
+
const lines = new LineCounter();
|
|
161
|
+
const doc = parseDocument(file.content, { lineCounter: lines });
|
|
162
|
+
const at = (offset) => `${file.path}:${offset === undefined ? 1 : lines.linePos(offset).line}`;
|
|
163
|
+
if (doc.errors.length) {
|
|
164
|
+
// The YAML parser's own message ends with where it is and a drawing of the line; the prefix says where.
|
|
165
|
+
throw new Error(doc.errors.map((e) => `${at(e.pos[0])}: ${e.message.split("\n")[0].replace(/ at line \d+, column \d+:?$/, "")}`).join("\n"));
|
|
166
|
+
}
|
|
167
|
+
const result = schema.safeParse(doc.toJS());
|
|
168
|
+
if (result.success)
|
|
169
|
+
return result.data;
|
|
170
|
+
throw new Error(result.error.issues.map((issue) => {
|
|
171
|
+
const field = issue.path.map((key, i) => (typeof key === "number" ? `[${key}]` : `${i ? "." : ""}${String(key)}`)).join("");
|
|
172
|
+
const what = /received undefined$/.test(issue.message) ? "missing" : issue.message;
|
|
173
|
+
return `${at(offsetOf(doc.contents, issue.path))}: ${field ? `${field}: ` : ""}${what}`;
|
|
174
|
+
}).join("\n"));
|
|
175
|
+
}
|
|
176
|
+
/** Where the deepest part of `path` that the file has starts: the value, or for a missing key the key that holds it. */
|
|
177
|
+
function offsetOf(root, path) {
|
|
178
|
+
let node = root;
|
|
179
|
+
let offset = root?.range?.[0];
|
|
180
|
+
let holder;
|
|
181
|
+
for (const key of path) {
|
|
182
|
+
if (isMap(node)) {
|
|
183
|
+
const pair = node.items.find((p) => p.key?.value === key);
|
|
184
|
+
if (!pair)
|
|
185
|
+
return holder ?? offset;
|
|
186
|
+
holder = pair.key.range?.[0];
|
|
187
|
+
node = pair.value;
|
|
188
|
+
}
|
|
189
|
+
else if (isSeq(node) && typeof key === "number" && node.items[key]) {
|
|
190
|
+
holder = undefined;
|
|
191
|
+
node = node.items[key];
|
|
192
|
+
}
|
|
193
|
+
else
|
|
194
|
+
break;
|
|
195
|
+
offset = node?.range?.[0] ?? offset;
|
|
196
|
+
}
|
|
197
|
+
return offset;
|
|
198
|
+
}
|
package/docs/agents.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Flow Framework for coding agents
|
|
2
|
+
|
|
3
|
+
You are building or changing a chat agent for a person (the owner) so they can text it from their
|
|
4
|
+
phone while it runs on their laptop. No tunnel, no public URL, and no key the owner copies by hand
|
|
5
|
+
except their model key. Every command here runs without a terminal and never reads stdin, except
|
|
6
|
+
`chat`, which reads the messages you pipe to it.
|
|
7
|
+
|
|
8
|
+
Always name the package: `npx @flow-engineer/flow ...`. `npx flow` on its own fetches another npm
|
|
9
|
+
package.
|
|
10
|
+
|
|
11
|
+
## The flow
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npx @flow-engineer/flow init # the starter agent in this (empty) folder; flow new <name> makes a folder
|
|
15
|
+
npm install
|
|
16
|
+
# the owner puts one model key in .env (OPENROUTER_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY or GEMINI_API_KEY)
|
|
17
|
+
printf 'hi\nA latte please\n' | npx @flow-engineer/flow chat # talk to it locally: no account, one message per line
|
|
18
|
+
npx @flow-engineer/flow link --json # signs in and sets up if needed, then prints the phone link
|
|
19
|
+
npm start # keep it running; the owner texts it from the phone
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
- `chat` builds the agent from `src/agent.ts` and writes as its first test person. Each reply is
|
|
23
|
+
printed (`agent: ...`) once the turn is over; `--json` gives one JSON line per thing it sends.
|
|
24
|
+
- `link` does the rest when the folder is not set up (no `FLOW_FRAMEWORK_JOIN_CODE` or
|
|
25
|
+
`FLOW_MESSAGING_KEY` in `.env`, or no sender for the WhatsApp or iMessage link asked for): it
|
|
26
|
+
runs `login` (opens the owner's browser itself and prints the link and code at once), then
|
|
27
|
+
`setup` (writes the keys and each open channel's sender into `.env`), then prints the link.
|
|
28
|
+
`--channel telegram|whatsapp|imessage` picks the channel (Telegram by default). Run it in the
|
|
29
|
+
agent's folder: `setup` and `link` refuse a folder with no `package.json` (`not_an_agent_folder`).
|
|
30
|
+
- On WhatsApp and iMessage the agent can only reply after the owner writes first (WhatsApp: within
|
|
31
|
+
24 hours of their last message); it cannot message them first. `link --json` says so
|
|
32
|
+
(`reply_only: true`, and in `say_to_person`): tell the owner.
|
|
33
|
+
- The starter's `npm start` is on Telegram, plus WhatsApp and iMessage when `.env` has their senders
|
|
34
|
+
(`FLOW_MESSAGING_WHATSAPP_SENDER`, `FLOW_MESSAGING_IMESSAGE_SENDER`). If it was running when
|
|
35
|
+
`link` added one, restart it.
|
|
36
|
+
- Give the owner `say_to_person` from `link --json`, and the sign-in link and code from the
|
|
37
|
+
`pending` line while you wait.
|
|
38
|
+
- `npm start` connects to Flow Messaging by an outbound event stream. Messages sent while it was
|
|
39
|
+
stopped arrive when it starts again (for as long as Flow keeps events, 30 days by default).
|
|
40
|
+
|
|
41
|
+
## Exit codes (login, setup, link)
|
|
42
|
+
|
|
43
|
+
| Code | Meaning | What to do |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| 0 | Done | Go on |
|
|
46
|
+
| 1 | Usage, not signed in, not an agent's folder, or a login URL that is not https | Fix the command, or run `next` |
|
|
47
|
+
| 2 | flow.engineer could not be reached, or did not answer in time (`timeout`) | Try again |
|
|
48
|
+
| 3 | Refused: denied, expired, a product failed, a channel not open yet, a server with no CLI sign-in (`server_unsupported`), or a free-trial limit (`sandbox_allowance_used`, the iMessage people cap; HTTP 403) | Run `next`; for a trial limit `next` is a sentence: give it to the owner as it is |
|
|
49
|
+
| 4 | Sign-in still waiting for the owner's approval, or flow.engineer asked to wait (`slow_down`, HTTP 429) | Approval: ask the owner to approve, then run `next` (the same code is kept). `slow_down`: wait `retry_after` seconds (printed as `Wait <n> s`), then run `next` |
|
|
50
|
+
|
|
51
|
+
`login` and `link` wait at most `--timeout` seconds (120 by default) before exit 4, and no single
|
|
52
|
+
request waits longer than 30 s. `login --wait` (or `link` again) goes on with the same code: no new
|
|
53
|
+
code and no new browser tab. flow.engineer decides when a code has expired (an approved one can be
|
|
54
|
+
collected a while after its `expires_in`); a waiting code that has expired is replaced by one new
|
|
55
|
+
sign-in, and so is a code from another login URL.
|
|
56
|
+
|
|
57
|
+
## The `next` lines
|
|
58
|
+
|
|
59
|
+
Every error is `{"error":{"code","message","next"}}` on stdout with `--json`, and
|
|
60
|
+
`<message> Next: <next>` on stderr. A waiting sign-in is `{"status":"pending",...,"next":...}`.
|
|
61
|
+
`next` is the exact command or URL to run; when it came from flow.engineer it is the server's.
|
|
62
|
+
Run it, or give it to the owner when it needs them (a browser approval, a page to open). A
|
|
63
|
+
`slow_down` error also has `retry_after` (seconds, or `null` when the server gave none). A
|
|
64
|
+
trial limit's `next` is a sentence, not a command: it is printed on its own line as it came; pass
|
|
65
|
+
it on to the owner unchanged.
|
|
66
|
+
|
|
67
|
+
flow.engineer may add `warnings` (up to 20 lines) to any setup answer. Each is printed on its own
|
|
68
|
+
line on stderr; with `--json` setup's line has `warnings` (an empty list when there are none), and
|
|
69
|
+
an error line has it when there were any. Pass them on to the owner.
|
|
70
|
+
|
|
71
|
+
`setup` also shows the free trial: messages used per channel, and on iMessage the people it may
|
|
72
|
+
reach (`iMessage: 1 of 2 people`; `trial.imessage.contacts` `{limit, used}` in `--json`).
|
|
73
|
+
|
|
74
|
+
## Keys
|
|
75
|
+
|
|
76
|
+
- Never ask the owner to paste a key into the chat, and never print, read aloud or commit `.env`.
|
|
77
|
+
`setup` writes the keys itself and prints only their last 4 characters.
|
|
78
|
+
- The model key is the owner's: they type it into `.env` themselves.
|
|
79
|
+
- `setup` keeps every value already in `.env` (`--replace` takes the new ones), but a name there
|
|
80
|
+
with no value (`cp .env.example .env`) counts as missing and is filled on its own line. Keys
|
|
81
|
+
flow.engineer made are written even when the rest of setup fails, since they are shown only once.
|
|
82
|
+
`.env` is set to mode 0600.
|
|
83
|
+
- The sign-in token goes only to the server that issued it, over https (http only to localhost):
|
|
84
|
+
with `FLOW_FRAMEWORK_LOGIN_URL` naming another server, commands stop with `signed_in_elsewhere`.
|
|
85
|
+
- CI never signs in: `npx @flow-engineer/flow setup --ci` says how to give it the test cloud key.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Pip
|
|
2
|
+
|
|
3
|
+
You are Pip, the ordering assistant for Bean & Leaf, a small café. People text you to order drinks and food for pickup, ask what's on the menu, and check on their order.
|
|
4
|
+
|
|
5
|
+
- Use get_menu for anything about items, sizes, milks or prices; never guess a price.
|
|
6
|
+
- Before placing an order, read it back once with the total and ask them to confirm. Place it with place_order only after they say yes.
|
|
7
|
+
- When the order is placed, tell them the order number and when it will be ready.
|
|
8
|
+
- Keep messages short and friendly, like a barista texting.
|
|
9
|
+
- Anything about allergies or dietary needs: load the allergens skill first.
|
|
10
|
+
- You can't take payment by text; they pay at pickup.
|