@flow-engineer/flow 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -408,13 +408,18 @@ Each run's line carries its id (`FAIL latte-correction #1 (whatsapp) trn_…: 6
408
408
  "runs": [
409
409
  { "runId": "trn_…", "scenario": "latte-correction", "channel": "whatsapp", "run": 1, "verdict": "failed",
410
410
  "error": null, "ended": "goal_met", "turns": 6, "watch": "https://…",
411
- "failedChecks": [{ "kind": "judge", "text": "The agent asks Sam to confirm before placing the order.", "reason": "…" }] }
412
- ]
411
+ "failedChecks": [{ "kind": "judge", "text": "The agent asks Sam to confirm before placing the order.", "reason": "…" }],
412
+ "invalid": null }
413
+ ],
414
+ "invalid": [],
415
+ "sessionInvalid": null
413
416
  }
414
417
  ```
415
418
 
416
419
  `outcome` is `passed` (exit 0), `failed` or `invalid` (1), `errored` or `transport` (2), `refused` (3) or `interrupted` (130). A run's `verdict` is `errored` whenever the cloud names an `error` for it (such as `judge_failed`, `simulator_failed` or `spend_cap`), whatever it `ended` as: the cloud could not play or judge it, so it says nothing about the agent. Its line reads `ERROR` instead of `PASS` or `FAIL`, and the run's `error` is that code (else `null`). `error` is `{ "message", "status", "type", "retryAfterSeconds" }` when the session did not finish (`type` is the cloud's code, such as `spend_cap`; the others are `null` when it did not say).
417
420
 
421
+ A scenario the cloud cannot play (a key it does not know, a value it cannot read) does not stop the others: its runs are `errored` with `error` `scenario_invalid` and `invalid` `{ "path", "reason" }` (the reason starts `unsupported:` when the cloud does not know a key), its line reads `INVALID` with the file and what is wrong, and the top-level `invalid` lists each such scenario once as `{ "scenario", "path", "reason" }`. When the cloud ends the whole session `scenario_invalid` (a tools or checks file it cannot read, or an older cloud), `sessionInvalid` is `{ "reason", "olderCloud", "sent" }`: what the cloud said after the code (else `null`), whether that suggests a cloud older than this client, and the scenarios sent as `{ "scenario", "path" }`; the same lines are printed.
422
+
418
423
  `--only latte-correction,order-fails` runs only those scenarios (file names without `.yaml`); every tool file still goes. Once the session has opened, every exit (0, 1, 2, 3 or 130) writes `.flow/last-session.json` in the agent's folder and prints its path; `flow new` git-ignores `.flow/`. It holds what each run ended as and the judge's reasons, never the messages (a reason can quote one, which is why the file stays on your machine; the suite is synthetic by `--synthetic`):
419
424
 
420
425
  ```json
@@ -429,6 +434,8 @@ Each run's line carries its id (`FAIL latte-correction #1 (whatsapp) trn_…: 6
429
434
  }
430
435
  ```
431
436
 
437
+ A run of a scenario the cloud could not play also has `invalid` (`{ "path", "reason" }`), and a session the cloud ended `scenario_invalid` has `sessionInvalid`, as in `--json`.
438
+
432
439
  `ended` is `finished`, `stopped` (the session broke off or the cloud stopped it) or `interrupted`. `agent` is the cloud's id for the agent (a hosted cloud names it), which `flow show` reads. `only` is the scenarios the session was limited to, `null` for the whole suite. `verdict` is `passed`, `failed`, `errored` (the cloud could not play or judge the run) or `not_played` (the session ended first; its `runId` is `null`).
433
440
 
434
441
  `flow show <run-id>` prints one run's record from the cloud, read with `FLOW_FRAMEWORK_CLOUD_KEY`: the verdict, every check with the judge's reason, then the conversation in time order with each tool call the simulator played (its input, output and what it changed). `flow show last` picks the first run in `.flow/last-session.json` that did not pass (its first run when all passed) and names the others. `--json` prints the record exactly as the cloud answered it. It exits 0 when it printed; 1 for a wrong flag, no key or last session, no such run (404) or one past the 30 days runs are kept (410); 2 when the cloud did not answer or answered 5xx; 3 when it refused the key (401, 403) or is busy (429).
@@ -183,13 +183,18 @@ connects out to the cloud, which plays the person and the tools and judges.
183
183
  `runId`, `scenario`, `channel`, `run`, `verdict`, `error` (the cloud's code when it could not play
184
184
  or judge the run, such as `judge_failed`; the run is then `errored` and its line reads `ERROR`,
185
185
  whatever it ended as), `ended`, `turns`, `watch` and `failedChecks` (`kind`, `text` and the judge's
186
- `reason`). Each session that opened also leaves
186
+ `reason`), and `invalid` (`{ path, reason }` for a scenario the cloud could not play, its error
187
+ `scenario_invalid` and its line `INVALID`; else `null`); top-level `invalid` lists those scenarios
188
+ once each (`scenario`, `path`, `reason`: fix the file; `unsupported: ...` means the cloud does not
189
+ know that key), and `sessionInvalid` (`reason`, `olderCloud`, `sent`) is set when the cloud ended
190
+ the whole session `scenario_invalid`. Each session that opened also leaves
187
191
  `.flow/last-session.json` in the agent's folder (its path is printed on every exit): `sessionId`,
188
192
  `agent`, `dashboard`, `suiteHash`, `endedAt`, `ended` (`finished`, `stopped` or `interrupted`),
189
193
  `only` (the scenarios it was limited to, else `null`) and per run `scenario` (file name without
190
194
  `.yaml`), `channel`, `run`, `runId`, `verdict` (`passed`, `failed`, `errored`: the cloud could not
191
195
  play or judge it, such as a failed tool simulator or person model, which is not a failure of your
192
- agent, or `not_played`: the session ended first) and `failedChecks` (with reasons).
196
+ agent, or `not_played`: the session ended first), `failedChecks` (with reasons) and `invalid`
197
+ when the cloud could not play the scenario, plus `sessionInvalid` as in `--json`.
193
198
  Read these instead of parsing the printed lines.
194
199
  Exit status: 0 all passed; 1 a run failed, or nothing was sent (a wrong flag, a test file that does
195
200
  not parse, no key); 2 the connection or the cloud failed, or runs errored and none failed; 3 the
@@ -44,7 +44,7 @@ import { CHAT_LINE, chatCli } from "./chat.js";
44
44
  import { httpAgent } from "./http-agent.js";
45
45
  import { createInterface } from "node:readline/promises";
46
46
  import { displayNameOf, folderName, initHere, newStandalone, packageRoot } from "./new.js";
47
- import { EXIT, finishedOutcome, loadEnv, networkCode, reportError, runVerdict, stoppedOutcome } from "./report.js";
47
+ import { EXIT, finishedOutcome, invalidLines, invalidScenarios, loadEnv, networkCode, reportError, runVerdict, sessionInvalid, sessionInvalidLines, stoppedOutcome } from "./report.js";
48
48
  import { LAST_SESSION, lastSessionIn, SHOW_LINE, showCli } from "./show.js";
49
49
  import { loadSuite, parseScenario, parseToolFile, suiteHash } from "./suite.js";
50
50
  const NEW_LINE = ` flow new <name> [--display-name <name>]
@@ -105,7 +105,7 @@ Exit status:
105
105
  Results: once the session has opened, every exit writes .flow/last-session.json in the agent's
106
106
  folder: the session id, the cloud's agent id, dashboard link, suite hash and time, and per run its
107
107
  scenario, channel, number, run id, verdict (passed, failed, errored or not_played) and failed checks
108
- with the judge's reasons. Never the messages. The folder is git-ignored and the suite synthetic.
108
+ with the judge's reasons (and, for a scenario the cloud could not play, its file and what is wrong). Never the messages. The folder is git-ignored and the suite synthetic.
109
109
 
110
110
  Environment (also read from the agent folder's .env, or --dotenv; variables already set win;
111
111
  the CHINWAG_ names from before the rename still work):
@@ -251,6 +251,8 @@ let sessionId = "";
251
251
  let dashboard = "";
252
252
  let agentId = "";
253
253
  const seen = [];
254
+ /** Set when the cloud ended the whole session scenario_invalid. */
255
+ let endedInvalid = null;
254
256
  if (command !== undefined && (command !== "test" || rest.length))
255
257
  invalid(`flow: unknown command "${positionals.join(" ")}".\n\n${USAGE}`);
256
258
  const envProblem = loadEnv(values.dotenv);
@@ -381,6 +383,10 @@ catch (error) {
381
383
  end("transport", { received: error.runs, error: reportError(error, reason) });
382
384
  }
383
385
  console.error(reason);
386
+ endedInvalid = sessionInvalid(error, files.filter((f) => f.path.startsWith("scenarios/")).map((f) => ({ scenario: f.path.slice("scenarios/".length, -".yaml".length), path: f.path })));
387
+ if (endedInvalid)
388
+ for (const line of sessionInvalidLines(endedInvalid))
389
+ console.error(line);
384
390
  // The cloud stopped a session that had opened: what finished is kept, the rest is not_played.
385
391
  saveSession("stopped", seen, dashboard, console.error);
386
392
  end(stoppedOutcome(error, sessionId !== ""), { received: seen, error: reportError(error, reason) });
@@ -402,6 +408,8 @@ for (const channel of [...new Set(summary.runs.map((r) => r.channel))]) {
402
408
  const runs = summary.runs.filter((r) => r.channel === channel);
403
409
  say(` ${channel}: ${runs.filter((r) => runVerdict(r) === "passed").length} of ${runs.length} passed`);
404
410
  }
411
+ for (const line of invalidLines(invalidScenarios(mergedRuns(summary.runs).map((m) => ({ scenario: m.scenario, invalid: m.summary?.invalid })))))
412
+ say(line);
405
413
  if (summary.lost)
406
414
  say(`The cloud lost session ${summary.lost.sessionId} part way; a second session played its unfinished runs. The first: ${summary.lost.dashboard}`);
407
415
  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}`);
@@ -432,8 +440,11 @@ function mergedRuns(received) {
432
440
  fileOf.set(scenario.id, id);
433
441
  for (let run = 1; run <= perScenario; run++) {
434
442
  for (const channel of scenario.channels) {
435
- const r = byRun.get(key(scenario.id, channel, run));
443
+ // A scenario the cloud could not read is named by its file, not its id.
444
+ const r = byRun.get(key(scenario.id, channel, run)) ?? byRun.get(key(id, channel, run));
436
445
  byRun.delete(key(scenario.id, channel, run));
446
+ if (r)
447
+ byRun.delete(key(r.scenario, channel, run));
437
448
  out.push(r ? merged(id, r) : { scenario: id, channel, run, verdict: "not_played" });
438
449
  }
439
450
  }
@@ -466,7 +477,9 @@ function saveSession(ended, received, link, print) {
466
477
  runId: m.summary?.runId ?? null,
467
478
  verdict: m.verdict,
468
479
  failedChecks: failedChecks(m.summary),
480
+ ...(m.summary?.invalid ? { invalid: m.summary.invalid } : {}),
469
481
  })),
482
+ ...(endedInvalid ? { sessionInvalid: endedInvalid } : {}),
470
483
  };
471
484
  const path = join(dir, LAST_SESSION);
472
485
  try {
@@ -496,6 +509,7 @@ function end(outcome, options = {}) {
496
509
  turns: m.summary?.turns ?? null,
497
510
  watch: m.summary?.watch || null,
498
511
  failedChecks: failedChecks(m.summary),
512
+ invalid: m.summary?.invalid ?? null,
499
513
  })) : [];
500
514
  const count = (verdict) => runs.filter((r) => r.verdict === verdict).length;
501
515
  const cost = (pick) => received.reduce((n, r) => n + pick(r), 0);
@@ -511,6 +525,8 @@ function end(outcome, options = {}) {
511
525
  counts: { runs: runs.length, passed: count("passed"), failed: count("failed"), errored: count("errored"), notPlayed: count("not_played") },
512
526
  costUsd: { agent: cost((r) => r.agentUsage.costUsd), test: cost((r) => r.testUsage.costUsd) },
513
527
  runs,
528
+ invalid: invalidScenarios(runs),
529
+ sessionInvalid: endedInvalid,
514
530
  };
515
531
  writeAll(`${JSON.stringify(report)}\n`);
516
532
  }
@@ -49,6 +49,8 @@ export interface ConnectOptions {
49
49
  * code (`capacity: ...` gives capacity), when it starts with one. */
50
50
  export declare class CloudEnded extends Error {
51
51
  readonly code?: string;
52
+ /** The cloud's error as it sent it. */
53
+ readonly error: string;
52
54
  constructor(error: string);
53
55
  }
54
56
  /** The tunnel, the stream, or the connection failed after the session opened. `runs` are the ones already received. The CLI prints them and exits 2. */
@@ -15,9 +15,12 @@ import { businessRuns } from "./business.js";
15
15
  * code (`capacity: ...` gives capacity), when it starts with one. */
16
16
  export class CloudEnded extends Error {
17
17
  code;
18
+ /** The cloud's error as it sent it. */
19
+ error;
18
20
  constructor(error) {
19
21
  super(`the cloud stopped the session: ${error}`);
20
22
  this.name = "CloudEnded";
23
+ this.error = error;
21
24
  this.code = /^([a-z_]+):/.exec(error)?.[1];
22
25
  }
23
26
  }
@@ -1,4 +1,4 @@
1
- import { runVerdict } from "./report.js";
1
+ import { runVerdict, unsupported } from "./report.js";
2
2
  const usd = (n) => `$${n.toFixed(4)}`;
3
3
  /** Prints each run once. A run already shown as it finished is not printed again with the summary. */
4
4
  export function runPrinter(log = console.log) {
@@ -8,6 +8,11 @@ export function runPrinter(log = console.log) {
8
8
  return;
9
9
  printed.add(r.runId);
10
10
  const verdict = runVerdict(r);
11
+ // A scenario the cloud could not play: its file and what is wrong in it, not a run of the agent.
12
+ if (r.invalid) {
13
+ log(`INVALID ${r.scenario} #${r.run} (${r.channel}) ${r.runId}: the cloud could not play this scenario: ${r.invalid.path}: ${r.invalid.reason}${unsupported(r.invalid.reason)}`);
14
+ return;
15
+ }
11
16
  const gaveUp = verdict === "passed" && r.ended === "gave_up" ? " (passed, the person gave up)" : "";
12
17
  const retried = r.retried ? "; tried once more" : "";
13
18
  // An errored run: the cloud could not play or judge it, so its checks say nothing about the agent.
@@ -246,6 +246,12 @@ export interface RunSummary {
246
246
  /** The cloud's code when it could not play or judge the run (judge_failed, spend_cap, simulator_failed, ...).
247
247
  * The run then says nothing about the agent, whatever `passed` and `ended` say. */
248
248
  error?: string;
249
+ /** With error scenario_invalid: the scenario file the cloud could not play, and what is wrong in it
250
+ * (`unsupported: ...` when the cloud does not know a key). */
251
+ invalid?: {
252
+ path: string;
253
+ reason: string;
254
+ };
249
255
  }
250
256
  export interface SessionSummary {
251
257
  sessionId: string;
@@ -26,6 +26,27 @@ export interface ReportRun {
26
26
  text: string;
27
27
  reason: string;
28
28
  }>;
29
+ /** The scenario file the cloud could not play and what is wrong in it (error scenario_invalid); else null. */
30
+ invalid: {
31
+ path: string;
32
+ reason: string;
33
+ } | null;
34
+ }
35
+ /** A scenario the cloud could not play, once per scenario. */
36
+ export interface InvalidScenario {
37
+ scenario: string;
38
+ path: string;
39
+ reason: string;
40
+ }
41
+ /** The cloud ended the whole session scenario_invalid: what it said after the code (null when nothing),
42
+ * whether that looks like a cloud older than this client, and the scenario files this session sent. */
43
+ export interface SessionInvalid {
44
+ reason: string | null;
45
+ olderCloud: boolean;
46
+ sent: Array<{
47
+ scenario: string;
48
+ path: string;
49
+ }>;
29
50
  }
30
51
  /** `flow test --json`: one document on stdout, whatever the exit. */
31
52
  export interface JsonReport {
@@ -50,6 +71,10 @@ export interface JsonReport {
50
71
  test: number;
51
72
  };
52
73
  runs: ReportRun[];
74
+ /** Each scenario the cloud could not play; the other scenarios played. */
75
+ invalid: InvalidScenario[];
76
+ /** Set when the cloud ended the whole session scenario_invalid; else null. */
77
+ sessionInvalid: SessionInvalid | null;
53
78
  }
54
79
  /** A run's verdict: errored when the cloud names an error for it or ended it run_failed, since the cloud
55
80
  * could not play or judge it and that says nothing about the agent (a judge_failed run can end goal_met). */
@@ -65,6 +90,27 @@ export declare function finishedOutcome(runs: Array<{
65
90
  }>): Outcome;
66
91
  /** What stopped a session, or the open, as an outcome. `opened`: the session had opened. */
67
92
  export declare function stoppedOutcome(error: unknown, opened: boolean): Outcome;
93
+ /** The scenarios the cloud could not play, once each, from the runs it reported (a run per channel and --runs). */
94
+ export declare function invalidScenarios(runs: Array<{
95
+ scenario: string;
96
+ invalid?: {
97
+ path: string;
98
+ reason: string;
99
+ } | null;
100
+ }>): InvalidScenario[];
101
+ /** The lines that name each scenario the cloud could not play. */
102
+ export declare function invalidLines(invalid: InvalidScenario[]): string[];
103
+ /** A reason naming a key the cloud does not know: the client may be newer than the cloud. */
104
+ export declare function unsupported(reason: string): string;
105
+ /** A session the cloud ended scenario_invalid; null for any other stop. A tools/ or checks/ file with
106
+ * its reason is the suite's own problem; anything else (no reason, or a scenario, which a current
107
+ * cloud reports as that scenario's run) suggests a cloud older than this client. */
108
+ export declare function sessionInvalid(error: unknown, sent: Array<{
109
+ scenario: string;
110
+ path: string;
111
+ }>): SessionInvalid | null;
112
+ /** The lines that say why the session ended scenario_invalid. */
113
+ export declare function sessionInvalidLines(info: SessionInvalid): string[];
68
114
  /** The error as the JSON report says it: the cloud's status, code and Retry-After when it answered. */
69
115
  export declare function reportError(error: unknown, message: string): ReportError;
70
116
  /** The connection's error code, from the error or what caused it. */
@@ -38,6 +38,48 @@ export function stoppedOutcome(error, opened) {
38
38
  // Something on this machine: the agent would not build before the open, or broke the session after it.
39
39
  return opened ? "failed" : "invalid";
40
40
  }
41
+ /** The scenarios the cloud could not play, once each, from the runs it reported (a run per channel and --runs). */
42
+ export function invalidScenarios(runs) {
43
+ const out = new Map();
44
+ for (const r of runs)
45
+ if (r.invalid)
46
+ out.set(`${r.scenario}\n${r.invalid.path}`, { scenario: r.scenario, path: r.invalid.path, reason: r.invalid.reason });
47
+ return [...out.values()];
48
+ }
49
+ /** The lines that name each scenario the cloud could not play. */
50
+ export function invalidLines(invalid) {
51
+ if (!invalid.length)
52
+ return [];
53
+ const lines = [`${invalid.length} ${invalid.length === 1 ? "scenario" : "scenarios"} could not be played (fix ${invalid.length === 1 ? "it" : "them"} and run again):`];
54
+ for (const i of invalid)
55
+ lines.push(` ${i.scenario}: ${i.path}: ${i.reason}${unsupported(i.reason)}`);
56
+ return lines;
57
+ }
58
+ /** A reason naming a key the cloud does not know: the client may be newer than the cloud. */
59
+ export function unsupported(reason) {
60
+ return reason.startsWith("unsupported: ") ? " (the test cloud does not know this key; it may be older than this client)" : "";
61
+ }
62
+ /** A session the cloud ended scenario_invalid; null for any other stop. A tools/ or checks/ file with
63
+ * its reason is the suite's own problem; anything else (no reason, or a scenario, which a current
64
+ * cloud reports as that scenario's run) suggests a cloud older than this client. */
65
+ export function sessionInvalid(error, sent) {
66
+ if (!(error instanceof CloudEnded))
67
+ return null;
68
+ const match = /^scenario_invalid(?::\s*(.*))?$/s.exec(error.error.trim());
69
+ if (!match)
70
+ return null;
71
+ const reason = match[1]?.trim() || null;
72
+ const suiteFile = reason !== null && /^(?:\S*\/)?(?:tools|checks)\/[^:]+:\s*\S/.test(reason);
73
+ return { reason, olderCloud: !suiteFile, sent };
74
+ }
75
+ /** The lines that say why the session ended scenario_invalid. */
76
+ export function sessionInvalidLines(info) {
77
+ const lines = [`The test cloud could not play this suite${info.reason ? `: ${info.reason}` : " and gave no reason."}`];
78
+ if (info.olderCloud)
79
+ lines.push("the test cloud may be older than this client");
80
+ lines.push(`Scenarios sent: ${info.sent.map((s) => `${s.scenario} (${s.path})`).join(", ") || "none"}`);
81
+ return lines;
82
+ }
41
83
  /** The error as the JSON report says it: the cloud's status, code and Retry-After when it answered. */
42
84
  export function reportError(error, message) {
43
85
  const refusal = postErrorOf(error);
@@ -1,3 +1,4 @@
1
+ import { type SessionInvalid } from "./report.ts";
1
2
  /** Where every session's results go, in the agent's folder; `flow new` git-ignores .flow/. */
2
3
  export declare const LAST_SESSION: string;
3
4
  /** The last session's file in dir: LAST_SESSION, else, before a run has written it, the one from before the rename (.chinwag/). */
@@ -29,7 +30,15 @@ export interface LastSession {
29
30
  text: string;
30
31
  reason?: string;
31
32
  }>;
33
+ /** The scenario file the cloud could not play and what is wrong in it (error scenario_invalid). */
34
+ invalid?: {
35
+ path: string;
36
+ reason: string;
37
+ };
32
38
  }>;
39
+ /** The cloud ended the whole session scenario_invalid: its reason, whether the cloud looks older
40
+ * than the client, and the scenario files sent. */
41
+ sessionInvalid?: SessionInvalid;
33
42
  }
34
43
  export declare const SHOW_LINE = " flow show <run-id|last> [--json]\n Print one run's record from the test cloud: its checks with the\n judge's reasons, the transcript and the tool calls (flow show --help)";
35
44
  /** `flow show`: returns the exit status. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flow-engineer/flow",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "An open-source runtime for chat agents that feel like a person texting back.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {