@flow-engineer/flow 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +594 -2
- package/coding-agents/skill.md +321 -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 +34 -0
- package/dist/flow-messaging/src/client.js +130 -0
- package/dist/flow-messaging/src/index.d.ts +37 -0
- package/dist/flow-messaging/src/index.js +409 -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 +17 -0
- package/dist/src/names.js +38 -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/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 +563 -0
- package/dist/testing/src/client.d.ts +115 -0
- package/dist/testing/src/client.js +955 -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 +9 -0
- package/dist/testing/src/new.js +122 -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/examples/cafe/agent/AGENTS.md +10 -0
- package/examples/cafe/agent/skills/allergens/SKILL.md +7 -0
- package/examples/cafe/src/agent.ts +39 -0
- package/examples/cafe/src/main.ts +16 -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 +152 -0
- package/package.json +65 -4
- 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,563 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The flow command. `flow new <name>` makes ./<name>/, an agent started from the café example
|
|
3
|
+
// (testing/src/new.ts). `flow test`, or `flow` with flags, runs an agent's tests in the cloud,
|
|
4
|
+
// from the agent's folder:
|
|
5
|
+
// flow test [--cloud https://flow.engineer/observe] [--runs 3] [--only late-order,refund | --failed] [--json]
|
|
6
|
+
// A hosted cloud (any --cloud off this machine, the default included) needs the
|
|
7
|
+
// agent's CI key in FLOW_FRAMEWORK_CLOUD_KEY and --synthetic, the attestation that the test files hold no real
|
|
8
|
+
// customer or member data. --agent-version (else GITHUB_SHA), --ref (else GitHub's branch) and
|
|
9
|
+
// --environment (staging or production) are kept with the session.
|
|
10
|
+
// The agent's src/agent.ts exports buildAgent({ model, events, tools }) and its tools; its tests are
|
|
11
|
+
// in tests/scenarios and tests/tools. An agent built on anything else is tested through its HTTP
|
|
12
|
+
// endpoint instead, with --agent-url (testing/src/http-agent.ts): its own tools run for real.
|
|
13
|
+
// `flow check-endpoint <url>` checks such an endpoint against spec/ first (testing/src/check-endpoint.ts).
|
|
14
|
+
// Once a session has opened, its results go to .flow/last-session.json in the agent's folder
|
|
15
|
+
// (what each run ended as and the judge's reasons, never the messages), and --failed runs again the
|
|
16
|
+
// scenarios that did not pass. --json prints one report (testing/src/report.ts) on stdout at every exit,
|
|
17
|
+
// and `flow show <run>` prints one run's record from the cloud (testing/src/show.ts).
|
|
18
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
19
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
20
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
21
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return path;
|
|
25
|
+
};
|
|
26
|
+
import { mkdirSync, existsSync, readFileSync, renameSync, writeFileSync, writeSync } from "node:fs";
|
|
27
|
+
import { request as httpRequest } from "node:http";
|
|
28
|
+
import { request as httpsRequest } from "node:https";
|
|
29
|
+
import { basename, dirname, join } from "node:path";
|
|
30
|
+
import { pathToFileURL } from "node:url";
|
|
31
|
+
import { parseArgs } from "node:util";
|
|
32
|
+
import { OBSERVE_URL, setting } from "../../src/traces.js";
|
|
33
|
+
import { cloudHeaders, runInCloud, SessionStopped } from "./client.js";
|
|
34
|
+
import { runPrinter } from "./print-run.js";
|
|
35
|
+
import { checkEndpointCli } from "./check-endpoint.js";
|
|
36
|
+
import { httpAgent } from "./http-agent.js";
|
|
37
|
+
import { newStandalone, packageRoot } from "./new.js";
|
|
38
|
+
import { EXIT, finishedOutcome, loadEnv, networkCode, reportError, runVerdict, stoppedOutcome } from "./report.js";
|
|
39
|
+
import { LAST_SESSION, lastSessionIn, SHOW_LINE, showCli } from "./show.js";
|
|
40
|
+
import { loadSuite, parseScenario, parseToolFile, suiteHash } from "./suite.js";
|
|
41
|
+
const NEW_LINE = " flow new <name> Make ./<name>/, a new agent started from the café example";
|
|
42
|
+
const TEST_LINE = ` flow test [options] Run the agent's tests in a test cloud, from the agent's folder
|
|
43
|
+
(src/agent.ts exports buildAgent({ model, events, tools }) and tools;
|
|
44
|
+
the tests are in tests/scenarios and tests/tools)`;
|
|
45
|
+
const CHECK_LINE = ` flow check-endpoint <url> [--token-env NAME] [--json]
|
|
46
|
+
Check an agent's HTTP endpoint against the contract, for --agent-url
|
|
47
|
+
(flow check-endpoint --help)`;
|
|
48
|
+
const NEW_USAGE = `Usage:\n${NEW_LINE}\n\n -h, --help Show this help`;
|
|
49
|
+
const TEST_OPTIONS = `Options for test:
|
|
50
|
+
--cloud <url> The test cloud (default: FLOW_FRAMEWORK_CLOUD, else https://flow.engineer/observe)
|
|
51
|
+
--runs <n> Runs per scenario (default: 1)
|
|
52
|
+
--only <ids> Only these scenarios, comma-separated (file names without .yaml)
|
|
53
|
+
--failed Only the scenarios that failed, errored or never played in the last
|
|
54
|
+
session (.flow/last-session.json), on every channel each plays.
|
|
55
|
+
Goes with --runs, not with --only
|
|
56
|
+
--synthetic Attest the test files hold no real customer data (a hosted cloud needs it)
|
|
57
|
+
--json Print one JSON report on stdout when it ends, whatever the exit; every
|
|
58
|
+
other line goes to stderr
|
|
59
|
+
--dotenv <path> Read the environment from this file instead of the folder's .env
|
|
60
|
+
--verbose Also print slow event uploads
|
|
61
|
+
--agent-version <v> Kept with the session (default: GITHUB_SHA)
|
|
62
|
+
--ref <ref> Kept with the session (default: GitHub's branch)
|
|
63
|
+
--environment <name> staging or production, kept with the session
|
|
64
|
+
--agent-url <url> Test an agent you already have, on any framework, through its HTTP
|
|
65
|
+
endpoint (it may be http://localhost): each message is a POST to it from
|
|
66
|
+
this machine, and no src/agent.ts is loaded
|
|
67
|
+
--channel <name> With --agent-url: the channel every message names (default: the
|
|
68
|
+
platform each run plays, such as whatsapp or sms)
|
|
69
|
+
--agent-timeout <s> With --agent-url: how long one message waits for its answer (default: 60)
|
|
70
|
+
-h, --help Show this help
|
|
71
|
+
|
|
72
|
+
Exit status:
|
|
73
|
+
0 Every run passed
|
|
74
|
+
1 A run failed. Or nothing was sent: a wrong flag, a test file that does not parse,
|
|
75
|
+
no FLOW_FRAMEWORK_CLOUD_KEY. The message is printed on its own
|
|
76
|
+
2 The connection, the tunnel or the stream failed (before or after the session opened),
|
|
77
|
+
the cloud answered 5xx or no JSON, the cloud ended the session for its own failure, or
|
|
78
|
+
runs errored (the cloud could not play or judge them) and none failed. After the open:
|
|
79
|
+
the runs that had finished, then "N of M runs finished before: <reason>" and the dashboard
|
|
80
|
+
3 The cloud refused: the open (a bad or wrong key 401/403, an invalid request 400, busy
|
|
81
|
+
429 rate_limited, the spend cap 429 spend_cap, 503 capacity), or it ended the session
|
|
82
|
+
for the spend cap or capacity. A busy open with Retry-After up to 60 s is sent once more
|
|
83
|
+
130 Interrupted (Ctrl-C). The same partial report as exit 2. The cloud keeps
|
|
84
|
+
playing until it is stopped from the dashboard
|
|
85
|
+
|
|
86
|
+
Results: once the session has opened, every exit writes .flow/last-session.json in the agent's
|
|
87
|
+
folder: the session id, the cloud's agent id, dashboard link, suite hash and time, and per run its
|
|
88
|
+
scenario, channel, number, run id, verdict (passed, failed, errored or not_played) and failed checks
|
|
89
|
+
with the judge's reasons. Never the messages. The folder is git-ignored and the suite synthetic.
|
|
90
|
+
|
|
91
|
+
Environment (also read from the agent folder's .env, or --dotenv; variables already set win;
|
|
92
|
+
the CHINWAG_ names from before the rename still work):
|
|
93
|
+
FLOW_FRAMEWORK_CLOUD The test cloud, when --cloud is not given
|
|
94
|
+
FLOW_FRAMEWORK_CLOUD_KEY The agent's CI key, for a hosted cloud (oci_...: make one at
|
|
95
|
+
https://flow.engineer/observe, your agent, Developers)
|
|
96
|
+
FLOW_FRAMEWORK_AGENT_TOKEN
|
|
97
|
+
With --agent-url: sent to it as authorization: Bearer <token>
|
|
98
|
+
OPENROUTER_API_KEY The agent's model key, or OPENAI_API_KEY, ANTHROPIC_API_KEY or
|
|
99
|
+
GEMINI_API_KEY: the first with a value is used`;
|
|
100
|
+
const TEST_USAGE = `Usage:\n${TEST_LINE}\n\n${TEST_OPTIONS}`;
|
|
101
|
+
const USAGE = `Usage:\n${NEW_LINE}\n${TEST_LINE}\n${SHOW_LINE}\n${CHECK_LINE}\n flow --version Show the version\n\n${TEST_OPTIONS}`;
|
|
102
|
+
checkNode(process.versions.node);
|
|
103
|
+
if (process.argv[2] === "check-endpoint") {
|
|
104
|
+
// Its token may be in the agent folder's .env; no value is ever printed.
|
|
105
|
+
loadEnv(undefined);
|
|
106
|
+
process.exit(await checkEndpointCli(process.argv.slice(3)));
|
|
107
|
+
}
|
|
108
|
+
if (process.argv[2] === "show")
|
|
109
|
+
process.exit(await showCli(process.argv.slice(3)));
|
|
110
|
+
const TEST_FLAGS = {
|
|
111
|
+
cloud: { type: "string" },
|
|
112
|
+
runs: { type: "string", default: "1" },
|
|
113
|
+
/** Only these scenarios (file names without .yaml, comma-separated); every tool file still goes. */
|
|
114
|
+
only: { type: "string" },
|
|
115
|
+
/** Only the scenarios that did not pass in .flow/last-session.json. */
|
|
116
|
+
failed: { type: "boolean", default: false },
|
|
117
|
+
synthetic: { type: "boolean", default: false },
|
|
118
|
+
json: { type: "boolean", default: false },
|
|
119
|
+
dotenv: { type: "string" },
|
|
120
|
+
verbose: { type: "boolean", default: false },
|
|
121
|
+
"agent-version": { type: "string" },
|
|
122
|
+
ref: { type: "string" },
|
|
123
|
+
environment: { type: "string" },
|
|
124
|
+
"agent-url": { type: "string" },
|
|
125
|
+
channel: { type: "string" },
|
|
126
|
+
"agent-timeout": { type: "string", default: "60" },
|
|
127
|
+
help: { type: "boolean", short: "h", default: false },
|
|
128
|
+
version: { type: "boolean", default: false },
|
|
129
|
+
};
|
|
130
|
+
const { values, positionals } = parseCommandLine();
|
|
131
|
+
const [command, ...rest] = positionals;
|
|
132
|
+
if (values.version) {
|
|
133
|
+
console.log(JSON.parse(readFileSync(join(packageRoot(), "package.json"), "utf8")).version);
|
|
134
|
+
process.exit(0);
|
|
135
|
+
}
|
|
136
|
+
if (values.help || process.argv.length <= 2) {
|
|
137
|
+
console.log(command === "new" ? NEW_USAGE : command === "test" ? TEST_USAGE : USAGE);
|
|
138
|
+
process.exit(0);
|
|
139
|
+
}
|
|
140
|
+
if (command === "new") {
|
|
141
|
+
const name = rest.length === 1 ? rest[0] : "";
|
|
142
|
+
try {
|
|
143
|
+
newStandalone(name);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
console.error(name ? error.message : "Usage: flow new <name>");
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
console.log(`Made ./${name}/ from the café example (Pip). Next:
|
|
150
|
+
1. cd ${name} && npm install
|
|
151
|
+
2. Add one model key: cp .env.example .env; the file says where to make one
|
|
152
|
+
3. npm start, then open the link it prints
|
|
153
|
+
4. Tell your coding agent what to build: its skills are in .claude/skills/`);
|
|
154
|
+
process.exit(0);
|
|
155
|
+
}
|
|
156
|
+
/** Every human line of `flow test`: stdout, or stderr with --json, where stdout is the report. */
|
|
157
|
+
const say = values.json ? (line) => console.error(line) : (line) => console.log(line);
|
|
158
|
+
// What the report says; filled in as the command gets that far.
|
|
159
|
+
let files = [];
|
|
160
|
+
let picked;
|
|
161
|
+
let perScenario = 1;
|
|
162
|
+
let sessionId = "";
|
|
163
|
+
let dashboard = "";
|
|
164
|
+
let agentId = "";
|
|
165
|
+
const seen = [];
|
|
166
|
+
if (command !== undefined && (command !== "test" || rest.length))
|
|
167
|
+
invalid(`flow: unknown command "${positionals.join(" ")}".\n\n${USAGE}`);
|
|
168
|
+
const envProblem = loadEnv(values.dotenv);
|
|
169
|
+
if (envProblem)
|
|
170
|
+
invalid(envProblem);
|
|
171
|
+
const runs = checkRuns(values.runs);
|
|
172
|
+
const cloud = values.cloud || setting("CLOUD") || OBSERVE_URL;
|
|
173
|
+
const ciKey = setting("CLOUD_KEY") || undefined;
|
|
174
|
+
let openHeaders = {};
|
|
175
|
+
try {
|
|
176
|
+
openHeaders = cloudHeaders({
|
|
177
|
+
cloud,
|
|
178
|
+
ciKey,
|
|
179
|
+
synthetic: values.synthetic,
|
|
180
|
+
agentVersion: values["agent-version"] || process.env.GITHUB_SHA || undefined,
|
|
181
|
+
ref: values.ref || process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || undefined,
|
|
182
|
+
environment: values.environment,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
const where = !ciKey && cloud.replace(/\/+$/, "") === OBSERVE_URL ? ` Make a key at ${OBSERVE_URL}: your agent, Developers.` : "";
|
|
187
|
+
invalid(`${error.message}${where}`);
|
|
188
|
+
}
|
|
189
|
+
const dir = process.cwd();
|
|
190
|
+
const agentUrl = values["agent-url"];
|
|
191
|
+
if (!agentUrl && values.channel)
|
|
192
|
+
invalid("--channel goes with --agent-url.");
|
|
193
|
+
// The package name keeps the agent's history together; an agent tested by its URL may have none.
|
|
194
|
+
const packageJson = join(dir, "package.json");
|
|
195
|
+
const name = (existsSync(packageJson) ? JSON.parse(readFileSync(packageJson, "utf8")).name : undefined) ?? (agentUrl ? basename(dir) : "agent");
|
|
196
|
+
// The agent's name for people: the first heading of its prompt (agent/AGENTS.md), such as "Pip".
|
|
197
|
+
const prompt = join(dir, "agent/AGENTS.md");
|
|
198
|
+
const title = existsSync(prompt) ? /^#\s+(.+)$/m.exec(readFileSync(prompt, "utf8"))?.[1]?.trim() : undefined;
|
|
199
|
+
let agent;
|
|
200
|
+
if (agentUrl) {
|
|
201
|
+
const timeout = Number(values["agent-timeout"]);
|
|
202
|
+
if (!/^https?:\/\/[^/]/.test(agentUrl) || !URL.canParse(agentUrl))
|
|
203
|
+
invalid("--agent-url must be an http:// or https:// URL, such as http://localhost:3000/flow.");
|
|
204
|
+
if (!(timeout > 0))
|
|
205
|
+
invalid("--agent-timeout must be a number of seconds above 0.");
|
|
206
|
+
const token = setting("AGENT_TOKEN") || undefined;
|
|
207
|
+
agent = { tools: [], build: ({ events }) => httpAgent({ url: agentUrl, token, channel: values.channel, timeoutMs: timeout * 1000, events }) };
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
if (!existsSync(join(dir, "src/agent.ts"))) {
|
|
211
|
+
invalid(`No src/agent.ts in ${dir}. Run flow test from your agent's folder, where src/agent.ts exports buildAgent({ model, events, tools }) and tools, or pass --agent-url to test an agent on another framework by its HTTP endpoint.`);
|
|
212
|
+
}
|
|
213
|
+
// Flow Framework runs the agent here, so its traces are marked as a test's (src/traces.ts).
|
|
214
|
+
process.env.FLOW_FRAMEWORK_RUN_KIND = "test";
|
|
215
|
+
const module = await import(__rewriteRelativeImportExtension(pathToFileURL(join(dir, "src/agent.ts")).href));
|
|
216
|
+
if (typeof module.buildAgent !== "function" || !Array.isArray(module.tools)) {
|
|
217
|
+
invalid("src/agent.ts must export buildAgent({ model, events, tools }) and tools, or pass --agent-url to test an agent by its HTTP endpoint.");
|
|
218
|
+
}
|
|
219
|
+
agent = { tools: module.tools, build: ({ tools, events }) => module.buildAgent({ tools, events }) };
|
|
220
|
+
}
|
|
221
|
+
/** With --agent-url the agent's own tools run for real: the suite's tool simulators do not apply. */
|
|
222
|
+
function forUrl(files) {
|
|
223
|
+
if (!agentUrl)
|
|
224
|
+
return files;
|
|
225
|
+
const toolFiles = files.filter((f) => f.path.startsWith("tools/"));
|
|
226
|
+
if (toolFiles.length)
|
|
227
|
+
console.warn(`Not sent: ${toolFiles.map((f) => f.path).join(", ")}. Tool simulators play a Flow Framework agent's tools; your agent's own tools run for real.`);
|
|
228
|
+
for (const f of files.filter((f) => f.path.startsWith("scenarios/"))) {
|
|
229
|
+
const scenario = parseScenario(f); // checkSuite has read it already
|
|
230
|
+
if (scenario.world || scenario.expect.outcome?.length) {
|
|
231
|
+
console.warn(`${scenario.id}: its world and outcome checks are for simulated tools; your agent's tools run on its side, so the judge sees no tool calls. Running it anyway.`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return files.filter((f) => !f.path.startsWith("tools/"));
|
|
235
|
+
}
|
|
236
|
+
function only(files, ids) {
|
|
237
|
+
if (!ids)
|
|
238
|
+
return files;
|
|
239
|
+
const kept = files.filter((f) => !f.path.startsWith("scenarios/") || ids.has(f.path.slice("scenarios/".length, -".yaml".length)));
|
|
240
|
+
const missing = [...ids].filter((id) => !kept.some((f) => f.path === `scenarios/${id}.yaml`));
|
|
241
|
+
if (missing.length)
|
|
242
|
+
invalid(`No scenario named ${missing.join(", ")}.`);
|
|
243
|
+
return kept;
|
|
244
|
+
}
|
|
245
|
+
const usd = (n) => `$${n.toFixed(4)}`;
|
|
246
|
+
const printRun = runPrinter(say);
|
|
247
|
+
const suite = loadSuite(join(dir, "tests"));
|
|
248
|
+
picked = values.failed ? failedLastTime(suite) : values.only ? new Set(values.only.split(",").map((s) => s.trim())) : undefined;
|
|
249
|
+
const chosen = only(suite, picked);
|
|
250
|
+
checkSuite(chosen);
|
|
251
|
+
files = forUrl(chosen);
|
|
252
|
+
if (agentUrl)
|
|
253
|
+
await checkAgentReachable(agentUrl);
|
|
254
|
+
perScenario = runs;
|
|
255
|
+
const expected = expectedRuns(files, perScenario);
|
|
256
|
+
function reportStop(done, reason, link, ended) {
|
|
257
|
+
for (const run of done)
|
|
258
|
+
printRun(run);
|
|
259
|
+
console.error(`${done.length} of ${expected} runs finished before: ${reason}`);
|
|
260
|
+
if (link)
|
|
261
|
+
console.error(link);
|
|
262
|
+
if (ended === "interrupted" && link)
|
|
263
|
+
console.error(`the cloud keeps playing; stop it from the dashboard: ${link}`);
|
|
264
|
+
saveSession(ended, done, link, console.error);
|
|
265
|
+
}
|
|
266
|
+
process.on("SIGINT", () => {
|
|
267
|
+
reportStop(seen, "interrupted", dashboard, "interrupted");
|
|
268
|
+
end("interrupted", { received: seen, error: reportError(undefined, "interrupted") });
|
|
269
|
+
});
|
|
270
|
+
let summary;
|
|
271
|
+
try {
|
|
272
|
+
summary = await runInCloud({
|
|
273
|
+
cloud,
|
|
274
|
+
name,
|
|
275
|
+
title,
|
|
276
|
+
...agent,
|
|
277
|
+
files,
|
|
278
|
+
testsDir: join(dir, "tests"),
|
|
279
|
+
runs: perScenario,
|
|
280
|
+
openHeaders,
|
|
281
|
+
log: say,
|
|
282
|
+
verbose: values.verbose,
|
|
283
|
+
onRun: (run) => { seen.push(run); printRun(run); },
|
|
284
|
+
onDashboard: (url, id, cloudAgent) => { dashboard = url; sessionId = id; agentId = cloudAgent ?? agentId; process.env.FLOW_FRAMEWORK_TEST_SESSION = id; },
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
const reason = stoppedReason(error, cloud);
|
|
289
|
+
if (error instanceof SessionStopped) {
|
|
290
|
+
dashboard = error.dashboard || dashboard;
|
|
291
|
+
reportStop(error.runs, reason, dashboard, "stopped");
|
|
292
|
+
end("transport", { received: error.runs, error: reportError(error, reason) });
|
|
293
|
+
}
|
|
294
|
+
console.error(reason);
|
|
295
|
+
// The cloud stopped a session that had opened: what finished is kept, the rest is not_played.
|
|
296
|
+
saveSession("stopped", seen, dashboard, console.error);
|
|
297
|
+
end(stoppedOutcome(error, sessionId !== ""), { received: seen, error: reportError(error, reason) });
|
|
298
|
+
}
|
|
299
|
+
say("");
|
|
300
|
+
for (const r of summary.runs)
|
|
301
|
+
printRun(r);
|
|
302
|
+
const passed = summary.runs.filter((r) => runVerdict(r) === "passed").length;
|
|
303
|
+
const errored = summary.runs.filter((r) => runVerdict(r) === "errored").length;
|
|
304
|
+
const total = (pick) => summary.runs.reduce((n, r) => n + pick(r), 0);
|
|
305
|
+
const agentModels = [...new Set(summary.runs.flatMap((r) => r.agentModels))].join(", ") || "unknown";
|
|
306
|
+
const noModel = summary.runs.filter((r) => r.agentModels.length === 0 && r.agentUsage.input + r.agentUsage.output === 0).length;
|
|
307
|
+
const modelCalls = noModel ? `${noModel} ${noModel === 1 ? "run never called" : "runs never called"} the agent's model.` : "Every run called the agent's model.";
|
|
308
|
+
const checkFails = summary.runs.filter((r) => runVerdict(r) === "failed" && r.checks.some((c) => !c.pass)).length;
|
|
309
|
+
const erroredLine = errored ? ` ${errored} errored (the cloud could not play or judge ${errored === 1 ? "it" : "them"}).` : "";
|
|
310
|
+
const tokens = (n) => n.toLocaleString("en-US");
|
|
311
|
+
say(`\n${passed} of ${summary.runs.length} runs passed.${erroredLine} ${modelCalls} ${checkFails} failed a check. Agent: ${agentModels}; person, tools and judge: ${summary.testModel}.`);
|
|
312
|
+
for (const channel of [...new Set(summary.runs.map((r) => r.channel))]) {
|
|
313
|
+
const runs = summary.runs.filter((r) => r.channel === channel);
|
|
314
|
+
say(` ${channel}: ${runs.filter((r) => runVerdict(r) === "passed").length} of ${runs.length} passed`);
|
|
315
|
+
}
|
|
316
|
+
if (summary.lost)
|
|
317
|
+
say(`The cloud lost session ${summary.lost.sessionId} part way; a second session played its unfinished runs. The first: ${summary.lost.dashboard}`);
|
|
318
|
+
say(`Cost: ${usd(total((r) => r.agentUsage.costUsd))} agent (${tokens(total((r) => r.agentUsage.input))} in, ${tokens(total((r) => r.agentUsage.output))} out) + ${usd(total((r) => r.testUsage.costUsd))} test models.\n${summary.dashboard}`);
|
|
319
|
+
sessionId = summary.sessionId || sessionId;
|
|
320
|
+
dashboard = summary.dashboard || dashboard;
|
|
321
|
+
saveSession("finished", summary.runs, dashboard, say);
|
|
322
|
+
end(finishedOutcome(mergedRuns(summary.runs)), { received: summary.runs });
|
|
323
|
+
/** Each run sent (scenario file, channel, number) with its report; a run that never came is not_played. */
|
|
324
|
+
function mergedRuns(received) {
|
|
325
|
+
const sent = files.filter((f) => f.path.startsWith("scenarios/"));
|
|
326
|
+
const fileOf = new Map();
|
|
327
|
+
const byRun = new Map();
|
|
328
|
+
const key = (scenario, channel, run) => `${scenario}\n${channel}\n${run}`;
|
|
329
|
+
for (const r of received)
|
|
330
|
+
byRun.set(key(r.scenario, r.channel, r.run), r);
|
|
331
|
+
const merged = (scenario, r) => ({
|
|
332
|
+
scenario,
|
|
333
|
+
channel: r.channel,
|
|
334
|
+
run: r.run,
|
|
335
|
+
// An error or run_failed: the cloud could not play or judge the run, which says nothing about the agent.
|
|
336
|
+
verdict: runVerdict(r),
|
|
337
|
+
summary: r,
|
|
338
|
+
});
|
|
339
|
+
const out = [];
|
|
340
|
+
for (const file of sent) {
|
|
341
|
+
const scenario = parseScenario(file);
|
|
342
|
+
const id = file.path.slice("scenarios/".length, -".yaml".length);
|
|
343
|
+
fileOf.set(scenario.id, id);
|
|
344
|
+
for (let run = 1; run <= perScenario; run++) {
|
|
345
|
+
for (const channel of scenario.channels) {
|
|
346
|
+
const r = byRun.get(key(scenario.id, channel, run));
|
|
347
|
+
byRun.delete(key(scenario.id, channel, run));
|
|
348
|
+
out.push(r ? merged(id, r) : { scenario: id, channel, run, verdict: "not_played" });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// A run the cloud numbered or named differently is still kept, never lost.
|
|
353
|
+
for (const r of byRun.values())
|
|
354
|
+
out.push(merged(fileOf.get(r.scenario) ?? r.scenario, r));
|
|
355
|
+
return out;
|
|
356
|
+
}
|
|
357
|
+
/** The failed checks, with the judge's reasons: the reason can quote the conversation, which the suite attests is synthetic. */
|
|
358
|
+
function failedChecks(r) {
|
|
359
|
+
return (r?.checks ?? []).filter((c) => !c.pass).map((c) => ({ kind: c.kind, text: c.text, reason: c.reason }));
|
|
360
|
+
}
|
|
361
|
+
/** Writes the session's results, once it has opened, and prints where. */
|
|
362
|
+
function saveSession(ended, received, link, print) {
|
|
363
|
+
if (!sessionId)
|
|
364
|
+
return;
|
|
365
|
+
const last = {
|
|
366
|
+
sessionId,
|
|
367
|
+
...(agentId ? { agent: agentId } : {}),
|
|
368
|
+
dashboard: link,
|
|
369
|
+
suiteHash: suiteHash(files),
|
|
370
|
+
endedAt: new Date().toISOString(),
|
|
371
|
+
ended,
|
|
372
|
+
only: picked ? [...picked] : null,
|
|
373
|
+
runs: mergedRuns(received).map((m) => ({
|
|
374
|
+
scenario: m.scenario,
|
|
375
|
+
channel: m.channel,
|
|
376
|
+
run: m.run,
|
|
377
|
+
runId: m.summary?.runId ?? null,
|
|
378
|
+
verdict: m.verdict,
|
|
379
|
+
failedChecks: failedChecks(m.summary),
|
|
380
|
+
})),
|
|
381
|
+
};
|
|
382
|
+
const path = join(dir, LAST_SESSION);
|
|
383
|
+
try {
|
|
384
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
385
|
+
writeFileSync(`${path}.tmp`, `${JSON.stringify(last, null, 2)}\n`);
|
|
386
|
+
renameSync(`${path}.tmp`, path);
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
print(`Could not write ${LAST_SESSION}: ${error.message}`);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
print(`Results: ${LAST_SESSION} (flow test --failed runs again what did not pass)`);
|
|
393
|
+
}
|
|
394
|
+
/** Exits with the outcome's status, printing the JSON report on stdout first with --json. */
|
|
395
|
+
function end(outcome, options = {}) {
|
|
396
|
+
const exitCode = EXIT[outcome];
|
|
397
|
+
if (values.json) {
|
|
398
|
+
const received = options.received ?? [];
|
|
399
|
+
const runs = sessionId ? mergedRuns(received).map((m) => ({
|
|
400
|
+
runId: m.summary?.runId ?? null,
|
|
401
|
+
scenario: m.scenario,
|
|
402
|
+
channel: m.channel,
|
|
403
|
+
run: m.run,
|
|
404
|
+
verdict: m.verdict,
|
|
405
|
+
error: m.summary?.error || null,
|
|
406
|
+
ended: m.summary?.ended ?? null,
|
|
407
|
+
turns: m.summary?.turns ?? null,
|
|
408
|
+
watch: m.summary?.watch || null,
|
|
409
|
+
failedChecks: failedChecks(m.summary),
|
|
410
|
+
})) : [];
|
|
411
|
+
const count = (verdict) => runs.filter((r) => r.verdict === verdict).length;
|
|
412
|
+
const cost = (pick) => received.reduce((n, r) => n + pick(r), 0);
|
|
413
|
+
const report = {
|
|
414
|
+
version: 1,
|
|
415
|
+
outcome,
|
|
416
|
+
exitCode,
|
|
417
|
+
sessionId: sessionId || null,
|
|
418
|
+
agent: agentId || null,
|
|
419
|
+
dashboard: dashboard || null,
|
|
420
|
+
suiteHash: files.length ? suiteHash(files) : null,
|
|
421
|
+
error: options.error ?? null,
|
|
422
|
+
counts: { runs: runs.length, passed: count("passed"), failed: count("failed"), errored: count("errored"), notPlayed: count("not_played") },
|
|
423
|
+
costUsd: { agent: cost((r) => r.agentUsage.costUsd), test: cost((r) => r.testUsage.costUsd) },
|
|
424
|
+
runs,
|
|
425
|
+
};
|
|
426
|
+
writeAll(`${JSON.stringify(report)}\n`);
|
|
427
|
+
}
|
|
428
|
+
process.exit(exitCode);
|
|
429
|
+
}
|
|
430
|
+
/** Nothing was sent: prints what is wrong and exits 1 (with --json, after the report). */
|
|
431
|
+
function invalid(message) {
|
|
432
|
+
console.error(message);
|
|
433
|
+
end("invalid", { error: reportError(undefined, message) });
|
|
434
|
+
}
|
|
435
|
+
/** stdout in full before the exit, even when it is a pipe the reader is slow to empty. */
|
|
436
|
+
function writeAll(text) {
|
|
437
|
+
let bytes = Buffer.from(text);
|
|
438
|
+
while (bytes.length) {
|
|
439
|
+
try {
|
|
440
|
+
bytes = bytes.subarray(writeSync(1, bytes));
|
|
441
|
+
}
|
|
442
|
+
catch (error) {
|
|
443
|
+
if (error.code !== "EAGAIN")
|
|
444
|
+
throw error;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/** The scenarios that did not pass in the last session, or exits: 0 when nothing failed, 1 when there is no last session. */
|
|
449
|
+
function failedLastTime(suite) {
|
|
450
|
+
if (values.only)
|
|
451
|
+
invalid("--failed picks its scenarios from the last session and does not go with --only: run one or the other.");
|
|
452
|
+
const path = lastSessionIn(dir);
|
|
453
|
+
if (!existsSync(path))
|
|
454
|
+
invalid(`No ${LAST_SESSION} in ${dir}: run flow test first, then flow test --failed runs again what did not pass.`);
|
|
455
|
+
let last;
|
|
456
|
+
try {
|
|
457
|
+
last = JSON.parse(readFileSync(path, "utf8"));
|
|
458
|
+
if (!Array.isArray(last.runs))
|
|
459
|
+
throw new Error("it has no runs");
|
|
460
|
+
}
|
|
461
|
+
catch (error) {
|
|
462
|
+
invalid(`${LAST_SESSION} could not be read (${error.message}): run flow test again.`);
|
|
463
|
+
}
|
|
464
|
+
const ids = [...new Set(last.runs.filter((r) => r.verdict !== "passed").map((r) => r.scenario))];
|
|
465
|
+
if (!ids.length) {
|
|
466
|
+
say(`Nothing failed in the last session (${last.sessionId}).`);
|
|
467
|
+
if (last.only)
|
|
468
|
+
say(`It ran only ${last.only.join(", ")}: run flow test for the whole suite.`);
|
|
469
|
+
end("passed");
|
|
470
|
+
}
|
|
471
|
+
const have = new Set(suite.filter((f) => f.path.startsWith("scenarios/")).map((f) => f.path.slice("scenarios/".length, -".yaml".length)));
|
|
472
|
+
const gone = ids.filter((id) => !have.has(id));
|
|
473
|
+
const kept = ids.filter((id) => have.has(id));
|
|
474
|
+
if (gone.length)
|
|
475
|
+
console.warn(`No longer in tests/scenarios, skipped: ${gone.join(", ")}.`);
|
|
476
|
+
if (!kept.length)
|
|
477
|
+
invalid(`None of the scenarios that did not pass in session ${last.sessionId} are still in tests/scenarios: run flow test.`);
|
|
478
|
+
say(`Running again what did not pass in session ${last.sessionId}, on every channel each plays: ${kept.join(", ")}.`);
|
|
479
|
+
return new Set(kept);
|
|
480
|
+
}
|
|
481
|
+
/** Scenarios actually sent, times the channels each one plays, times --runs. */
|
|
482
|
+
function expectedRuns(sent, runs) {
|
|
483
|
+
if (!(runs > 0))
|
|
484
|
+
return 0;
|
|
485
|
+
let count = 0;
|
|
486
|
+
for (const file of sent) {
|
|
487
|
+
if (!file.path.startsWith("scenarios/"))
|
|
488
|
+
continue;
|
|
489
|
+
count += parseScenario(file).channels.length * runs;
|
|
490
|
+
}
|
|
491
|
+
return count;
|
|
492
|
+
}
|
|
493
|
+
/** Why the session stopped or never opened; a network failure names the address and its code. */
|
|
494
|
+
function stoppedReason(error, cloud) {
|
|
495
|
+
const code = networkCode(error);
|
|
496
|
+
if (!code)
|
|
497
|
+
return error instanceof Error ? error.message : String(error);
|
|
498
|
+
return `Could not reach ${cloud}: ${code}.`;
|
|
499
|
+
}
|
|
500
|
+
/** The command's flags, or exits 1 with what is wrong and that command's usage. */
|
|
501
|
+
function parseCommandLine() {
|
|
502
|
+
const isNew = process.argv[2] === "new";
|
|
503
|
+
const options = isNew ? { help: TEST_FLAGS.help, version: TEST_FLAGS.version } : TEST_FLAGS;
|
|
504
|
+
try {
|
|
505
|
+
return parseArgs({ allowPositionals: true, strict: true, options: options });
|
|
506
|
+
}
|
|
507
|
+
catch (error) {
|
|
508
|
+
const { code, message } = error;
|
|
509
|
+
const unknown = code === "ERR_PARSE_ARGS_UNKNOWN_OPTION" ? /'([^']+)'/.exec(message)?.[1] : undefined;
|
|
510
|
+
const usage = isNew ? NEW_USAGE : process.argv[2] === "test" ? TEST_USAGE : USAGE;
|
|
511
|
+
console.error(`flow: ${unknown ? `unknown option ${unknown}` : message.split(". ")[0]}\n\n${usage}`);
|
|
512
|
+
process.exit(1);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
/** --runs as a whole number above 0, or exits 1. */
|
|
516
|
+
function checkRuns(runs) {
|
|
517
|
+
if (/^[1-9]\d*$/.test(runs))
|
|
518
|
+
return Number(runs);
|
|
519
|
+
invalid(`--runs must be a whole number above 0, such as 3 (got "${runs}").`);
|
|
520
|
+
}
|
|
521
|
+
/** Every scenario and tool file read as the cloud reads it, before anything is sent; exits 1 with each problem at its line. */
|
|
522
|
+
function checkSuite(sent) {
|
|
523
|
+
const problems = [];
|
|
524
|
+
for (const file of sent) {
|
|
525
|
+
const named = { path: join("tests", file.path), content: file.content };
|
|
526
|
+
try {
|
|
527
|
+
if (file.path.startsWith("scenarios/"))
|
|
528
|
+
parseScenario(named);
|
|
529
|
+
else if (!agentUrl)
|
|
530
|
+
parseToolFile(named);
|
|
531
|
+
}
|
|
532
|
+
catch (error) {
|
|
533
|
+
problems.push(error.message);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
if (!problems.length)
|
|
537
|
+
return;
|
|
538
|
+
invalid(`${problems.join("\n")}\n\nNothing was sent: fix the test files above.`);
|
|
539
|
+
}
|
|
540
|
+
/** One HEAD request to the agent's URL, which a turn never is: any HTTP answer means it is listening. */
|
|
541
|
+
async function checkAgentReachable(url) {
|
|
542
|
+
const request = url.startsWith("https:") ? httpsRequest : httpRequest;
|
|
543
|
+
const problem = await new Promise((resolve) => {
|
|
544
|
+
const req = request(url, { method: "HEAD", agent: false, timeout: 10_000 }, (response) => {
|
|
545
|
+
response.resume();
|
|
546
|
+
resolve(undefined);
|
|
547
|
+
});
|
|
548
|
+
req.on("timeout", () => req.destroy(Object.assign(new Error("no answer in 10 s"), { code: "ETIMEDOUT" })));
|
|
549
|
+
req.on("error", (error) => resolve(networkCode(error) ?? error.message));
|
|
550
|
+
req.end();
|
|
551
|
+
});
|
|
552
|
+
if (!problem)
|
|
553
|
+
return;
|
|
554
|
+
invalid(`Could not reach your agent at ${url}: ${problem}. Start it first, or check --agent-url.`);
|
|
555
|
+
}
|
|
556
|
+
/** Flow Framework runs agents' TypeScript as it is, which needs Node 22.18 or newer. */
|
|
557
|
+
function checkNode(version) {
|
|
558
|
+
const [major = 0, minor = 0] = version.split(".").map(Number);
|
|
559
|
+
if (major > 22 || (major === 22 && minor >= 18))
|
|
560
|
+
return;
|
|
561
|
+
console.error(`Flow Framework needs Node 22.18 or newer (you have ${version}).`);
|
|
562
|
+
process.exit(1);
|
|
563
|
+
}
|